@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
package/apiReference.ts
ADDED
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { DeclarationReader, type MemberFilter } from "./declarationReader";
|
|
3
|
+
|
|
4
|
+
type Show =
|
|
5
|
+
| { kind: "signature" }
|
|
6
|
+
| ({ kind: "members" } & MemberFilter)
|
|
7
|
+
| { kind: "props"; omit?: string[] }
|
|
8
|
+
| { kind: "declaration" };
|
|
9
|
+
|
|
10
|
+
interface Item {
|
|
11
|
+
name: string;
|
|
12
|
+
from?: string;
|
|
13
|
+
file?: string;
|
|
14
|
+
show: Show;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
interface Section {
|
|
18
|
+
title: string;
|
|
19
|
+
intro?: string;
|
|
20
|
+
items: Item[];
|
|
21
|
+
routeStages?: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const sections: Section[] = [
|
|
25
|
+
{
|
|
26
|
+
title: "Routes (`page/`)",
|
|
27
|
+
items: [
|
|
28
|
+
{ name: "page", from: "akanjs/client", show: { kind: "signature" } },
|
|
29
|
+
{ name: "layout", from: "akanjs/client", show: { kind: "signature" } },
|
|
30
|
+
{ name: "rootLayout", from: "akanjs/client", show: { kind: "signature" } },
|
|
31
|
+
{ name: "RouteBaseArgs", file: "client/route/routeArgs.ts", show: { kind: "props" } },
|
|
32
|
+
{
|
|
33
|
+
name: "router",
|
|
34
|
+
from: "akanjs/client",
|
|
35
|
+
show: {
|
|
36
|
+
kind: "members",
|
|
37
|
+
only: [
|
|
38
|
+
"push",
|
|
39
|
+
"replace",
|
|
40
|
+
"back",
|
|
41
|
+
"backOrFallback",
|
|
42
|
+
"refresh",
|
|
43
|
+
"redirect",
|
|
44
|
+
"notFound",
|
|
45
|
+
"canGoBack",
|
|
46
|
+
"getPath",
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
{ name: "RouteOptions", file: "client/router.ts", show: { kind: "props" } },
|
|
51
|
+
{ name: "Link", from: "akanjs/ui", show: { kind: "signature" } },
|
|
52
|
+
{ name: "CommonLinkProps", file: "ui/Link/Anchor.tsx", show: { kind: "props" } },
|
|
53
|
+
{ name: "usePageLocation", from: "akanjs/webkit", show: { kind: "signature" } },
|
|
54
|
+
],
|
|
55
|
+
routeStages: true,
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
title: "Services and adapters (`lib/_<service>/`, `srvkit/`)",
|
|
59
|
+
items: [
|
|
60
|
+
{ name: "adapt", from: "akanjs/service", show: { kind: "signature" } },
|
|
61
|
+
{ name: "serve", from: "akanjs/service", show: { kind: "signature" } },
|
|
62
|
+
{ name: "ServiceOptions", file: "service/serve.ts", show: { kind: "props" } },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
title: "Endpoints (`*.signal.ts`)",
|
|
67
|
+
intro:
|
|
68
|
+
"`endpoint(srv.<module>, ({ query, mutation, pubsub, message }) => ({ … }))`: each builder takes the return type and a `SignalOption`, then chains the stages below and ends in `.exec()`.",
|
|
69
|
+
items: [
|
|
70
|
+
{ name: "endpoint", from: "akanjs/signal", show: { kind: "signature" } },
|
|
71
|
+
{ name: "BuildEndpoint", file: "signal/endpointInfo.ts", show: { kind: "members", prefix: "" } },
|
|
72
|
+
{
|
|
73
|
+
name: "EndpointInfo",
|
|
74
|
+
file: "signal/endpointInfo.ts",
|
|
75
|
+
show: { kind: "members", only: ["param", "search", "body", "room", "msg", "with", "exec"] },
|
|
76
|
+
},
|
|
77
|
+
{ name: "EndpointArgProps", file: "signal/endpointInfo.ts", show: { kind: "props" } },
|
|
78
|
+
{
|
|
79
|
+
name: "SignalOption",
|
|
80
|
+
file: "signal/types.ts",
|
|
81
|
+
show: {
|
|
82
|
+
kind: "props",
|
|
83
|
+
omit: ["live", "lock", "onFailed", "once", "scheduleCron", "scheduleTime", "scheduleType"],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
},
|
|
88
|
+
{
|
|
89
|
+
title: "Dictionaries (`*.dictionary.ts`)",
|
|
90
|
+
items: [
|
|
91
|
+
{ name: "serviceDictionary", from: "akanjs/dictionary", show: { kind: "signature" } },
|
|
92
|
+
{ name: "FunctionTranslation", file: "dictionary/dictInfo.ts", show: { kind: "members" } },
|
|
93
|
+
{ name: "FieldTranslation", file: "dictionary/dictInfo.ts", show: { kind: "members" } },
|
|
94
|
+
],
|
|
95
|
+
},
|
|
96
|
+
{
|
|
97
|
+
title: "App config (`akan.config.ts`)",
|
|
98
|
+
items: [
|
|
99
|
+
{ name: "AppConfig", from: "akanjs", show: { kind: "declaration" } },
|
|
100
|
+
{ name: "AppConfigInput", from: "akanjs", show: { kind: "props" } },
|
|
101
|
+
{ name: "AkanWebOption", from: "akanjs", show: { kind: "declaration" } },
|
|
102
|
+
{ name: "AkanI18nConfig", file: "common/localeConfig.ts", show: { kind: "props" } },
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
const importOnly: Record<string, string[]> = {
|
|
108
|
+
"akanjs/base": ["Any", "Binary", "dayjs", "Float", "ID", "Int", "Upload"],
|
|
109
|
+
"akanjs/signal": ["internal", "None", "Public"],
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const conventionImports = [
|
|
113
|
+
'import { fetch, usePage, <Model> } from "@apps/<app>/client"; // pages and UI',
|
|
114
|
+
'import * as cnst from "../cnst"; // inside lib/<module>/, beside srv and Err',
|
|
115
|
+
'import { Err } from "../dict";',
|
|
116
|
+
'import * as srv from "../srv";',
|
|
117
|
+
'import { BookApi } from "@apps/<app>/srvkit"; // a layer barrel: common, srvkit, ui, webkit',
|
|
118
|
+
'import { signedIn } from "@libs/shared/webkit"; // a workspace created with --libs true',
|
|
119
|
+
];
|
|
120
|
+
|
|
121
|
+
const routeInternals = ["assertPattern", "resolveArgs", "toRouteModule"];
|
|
122
|
+
|
|
123
|
+
export class ApiReference {
|
|
124
|
+
static readonly guideline = "akanApi";
|
|
125
|
+
|
|
126
|
+
static target(cliDir: string) {
|
|
127
|
+
return path.join(cliDir, "guidelines", ApiReference.guideline, `${ApiReference.guideline}.instruction.md`);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
static render(akanjsDir: string): string {
|
|
131
|
+
const reader = new DeclarationReader(akanjsDir);
|
|
132
|
+
const imports = new Map<string, Set<string>>();
|
|
133
|
+
const body: string[] = [];
|
|
134
|
+
for (const section of sections) {
|
|
135
|
+
body.push(`## ${section.title}`, "");
|
|
136
|
+
if (section.intro) body.push(section.intro, "");
|
|
137
|
+
const blocks: string[] = [];
|
|
138
|
+
for (const item of section.items) {
|
|
139
|
+
if (item.from) ApiReference.#addImport(imports, item.from, item.name, reader);
|
|
140
|
+
blocks.push(ApiReference.#renderItem(reader, item));
|
|
141
|
+
}
|
|
142
|
+
if (section.routeStages) blocks.push(ApiReference.#routeStages(reader));
|
|
143
|
+
body.push(...blocks.flatMap((block) => [block, ""]));
|
|
144
|
+
}
|
|
145
|
+
for (const [from, names] of Object.entries(importOnly))
|
|
146
|
+
for (const name of names) ApiReference.#addImport(imports, from, name, reader);
|
|
147
|
+
const importLines = [...imports.entries()]
|
|
148
|
+
.sort(([a], [b]) => a.localeCompare(b))
|
|
149
|
+
.map(([from, names]) => `import { ${[...names].sort((a, b) => a.localeCompare(b)).join(", ")} } from "${from}";`);
|
|
150
|
+
return [
|
|
151
|
+
"# Akan.js API reference",
|
|
152
|
+
"",
|
|
153
|
+
"Generated from the akanjs sources of this release: the import paths and signatures a first app needs, so none of",
|
|
154
|
+
"them has to be looked up in `node_modules`. A stage's return type is the same chain, left out below.",
|
|
155
|
+
"",
|
|
156
|
+
"## Imports",
|
|
157
|
+
"",
|
|
158
|
+
"```ts",
|
|
159
|
+
...importLines,
|
|
160
|
+
...conventionImports,
|
|
161
|
+
"```",
|
|
162
|
+
"",
|
|
163
|
+
...body,
|
|
164
|
+
]
|
|
165
|
+
.join("\n")
|
|
166
|
+
.trimEnd()
|
|
167
|
+
.concat("\n");
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
static #addImport(imports: Map<string, Set<string>>, from: string, name: string, reader: DeclarationReader) {
|
|
171
|
+
reader.exported(from, name);
|
|
172
|
+
if (from === "akanjs") return;
|
|
173
|
+
const names = imports.get(from) ?? new Set<string>();
|
|
174
|
+
names.add(name);
|
|
175
|
+
imports.set(from, names);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
static #renderItem(reader: DeclarationReader, item: Item): string {
|
|
179
|
+
const symbol = item.from ? reader.exported(item.from, item.name) : reader.declared(item.file ?? "", item.name);
|
|
180
|
+
const where = item.from ? ` · \`${item.from}\`` : "";
|
|
181
|
+
const heading = `### \`${item.name}\`${where}`;
|
|
182
|
+
if (item.show.kind === "declaration")
|
|
183
|
+
return [heading, "", "```ts", reader.declarationText(symbol), "```"].join("\n");
|
|
184
|
+
if (item.show.kind === "signature")
|
|
185
|
+
return [heading, "", "```ts", ...reader.signatures(symbol, item.name), "```"].join("\n");
|
|
186
|
+
if (item.show.kind === "props")
|
|
187
|
+
return [heading, "", "```ts", ...reader.props(reader.declaredType(symbol), item.show.omit), "```"].join("\n");
|
|
188
|
+
const members = reader.members(reader.instanceType(symbol), item.show);
|
|
189
|
+
return [heading, "", "```ts", ...members.map(([, line]) => line), "```"].join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
static #routeStages(reader: DeclarationReader): string {
|
|
193
|
+
const stagesOf = (name: string) =>
|
|
194
|
+
new Map(
|
|
195
|
+
reader.members(reader.instanceType(reader.exported("akanjs/client", name)), {
|
|
196
|
+
omit: name === "page" ? [...routeInternals, "loadApp"] : routeInternals,
|
|
197
|
+
}),
|
|
198
|
+
);
|
|
199
|
+
const page = stagesOf("page");
|
|
200
|
+
const layout = stagesOf("layout");
|
|
201
|
+
const root = stagesOf("rootLayout");
|
|
202
|
+
const shared = [...page.keys()].filter((key) => layout.has(key) && root.has(key));
|
|
203
|
+
const only = (stages: Map<string, string>, without: Map<string, string>[]) =>
|
|
204
|
+
[...stages.entries()].filter(([key]) => without.every((other) => !other.has(key))).map(([, line]) => line);
|
|
205
|
+
return [
|
|
206
|
+
"### Route stages",
|
|
207
|
+
"",
|
|
208
|
+
"Shared by `page()`, `layout()` and `rootLayout()`:",
|
|
209
|
+
"",
|
|
210
|
+
"```ts",
|
|
211
|
+
...shared.map((key) => page.get(key) ?? ""),
|
|
212
|
+
"```",
|
|
213
|
+
"",
|
|
214
|
+
"Only on `page()`:",
|
|
215
|
+
"",
|
|
216
|
+
"```ts",
|
|
217
|
+
...only(page, [layout, root]),
|
|
218
|
+
"```",
|
|
219
|
+
"",
|
|
220
|
+
"On `layout()` and `rootLayout()`, where a line replaces its shared form above:",
|
|
221
|
+
"",
|
|
222
|
+
"```ts",
|
|
223
|
+
reader.declarationText(reader.declared("client/route/LayoutDefinition.ts", "LayoutExtra")),
|
|
224
|
+
...[...layout.entries()].filter(([key, line]) => page.get(key) !== line).map(([, line]) => line),
|
|
225
|
+
"```",
|
|
226
|
+
"",
|
|
227
|
+
"Only on `rootLayout()`, before `.param()` and `.search()`:",
|
|
228
|
+
"",
|
|
229
|
+
"```ts",
|
|
230
|
+
...only(root, [page, layout]),
|
|
231
|
+
"```",
|
|
232
|
+
].join("\n");
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (import.meta.main) {
|
|
237
|
+
const akanjsDir = path.resolve(import.meta.dir, "../../akanjs");
|
|
238
|
+
const target = ApiReference.target(path.resolve(import.meta.dir, "../cli"));
|
|
239
|
+
await Bun.write(target, ApiReference.render(akanjsDir));
|
|
240
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { afterEach, describe, expect, spyOn, test } from "bun:test";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
import { ApplicationBuildReporter, type ApplicationBuildResult } from "./applicationBuildReporter";
|
|
4
|
+
|
|
5
|
+
const resultFor = (environment: string): ApplicationBuildResult => ({
|
|
6
|
+
phases: [],
|
|
7
|
+
durationMs: 1_200,
|
|
8
|
+
outputDir: "dist/apps/shop",
|
|
9
|
+
artifactDir: "dist/apps/shop/.akan/artifact",
|
|
10
|
+
environment,
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
describe("ApplicationBuildReporter.printSummary", () => {
|
|
14
|
+
const rawLog = spyOn(Logger, "rawLog").mockImplementation(() => undefined);
|
|
15
|
+
afterEach(() => rawLog.mockClear());
|
|
16
|
+
|
|
17
|
+
test("names the flag and the variables a deployed build needs when the build is for local", () => {
|
|
18
|
+
ApplicationBuildReporter.printSummary(resultFor("local"));
|
|
19
|
+
|
|
20
|
+
const notice = rawLog.mock.calls.map(([line]) => line).at(-1);
|
|
21
|
+
expect(notice).toBe(ApplicationBuildReporter.localBuildNotice);
|
|
22
|
+
for (const part of ["akan build <app> --env main", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"])
|
|
23
|
+
expect(notice).toContain(part);
|
|
24
|
+
expect(notice).not.toContain("dev routes");
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test("adds nothing to a build for a deployed environment", () => {
|
|
28
|
+
ApplicationBuildReporter.printSummary(resultFor("main"));
|
|
29
|
+
|
|
30
|
+
expect(rawLog.mock.calls.map(([line]) => line)).not.toContain(ApplicationBuildReporter.localBuildNotice);
|
|
31
|
+
expect(rawLog.mock.calls.at(-1)?.[0]).toBe("Done in 1.2s");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test("lists the largest routes' JS before the paths, and how many more it left out", () => {
|
|
35
|
+
const routeJs = Array.from({ length: 12 }, (_, idx) => ({
|
|
36
|
+
routeId: `/:lang/r${idx}`,
|
|
37
|
+
files: 3,
|
|
38
|
+
rawBytes: (12 - idx) * 3 * 1024,
|
|
39
|
+
brotliBytes: (12 - idx) * 1024,
|
|
40
|
+
}));
|
|
41
|
+
ApplicationBuildReporter.printSummary({ ...resultFor("main"), routeJs });
|
|
42
|
+
|
|
43
|
+
const lines = rawLog.mock.calls.map(([line]) => line);
|
|
44
|
+
expect(lines.slice(1, 4)).toEqual([
|
|
45
|
+
"Route JS, largest first (brotli / raw):",
|
|
46
|
+
" /:lang/r0 12.0KB 36.0KB",
|
|
47
|
+
" /:lang/r1 11.0KB 33.0KB",
|
|
48
|
+
]);
|
|
49
|
+
expect(lines).toContain(" …and 2 more");
|
|
50
|
+
expect(lines.indexOf(" …and 2 more")).toBeLessThan(
|
|
51
|
+
lines.indexOf("Route artifacts: dist/apps/shop/.akan/artifact"),
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
});
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { realpathSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Logger } from "akanjs/common";
|
|
4
|
+
import { RouteJsBudget, type RouteJsBudgetWarning, type RouteJsSize } from "./routeJsBudget";
|
|
4
5
|
|
|
5
6
|
export interface ApplicationBuildPhaseResult {
|
|
6
7
|
id: string;
|
|
@@ -15,6 +16,9 @@ export interface ApplicationBuildResult {
|
|
|
15
16
|
durationMs: number;
|
|
16
17
|
outputDir: string;
|
|
17
18
|
artifactDir: string;
|
|
19
|
+
environment: string;
|
|
20
|
+
routeJs?: RouteJsSize[];
|
|
21
|
+
routeJsWarnings?: RouteJsBudgetWarning[];
|
|
18
22
|
}
|
|
19
23
|
|
|
20
24
|
export interface ApplicationBuildProgressReporter {
|
|
@@ -32,11 +36,19 @@ export class ApplicationBuildReporter {
|
|
|
32
36
|
|
|
33
37
|
static printSummary(result: ApplicationBuildResult) {
|
|
34
38
|
Logger.rawLog("");
|
|
39
|
+
if (result.routeJs?.length) {
|
|
40
|
+
for (const line of RouteJsBudget.formatTable(result.routeJs)) Logger.rawLog(line);
|
|
41
|
+
Logger.rawLog("");
|
|
42
|
+
}
|
|
35
43
|
Logger.rawLog(`Route artifacts: ${result.artifactDir}`);
|
|
36
44
|
Logger.rawLog(`Server output: ${result.outputDir}`);
|
|
37
45
|
Logger.rawLog(`Done in ${ApplicationBuildReporter.formatDuration(result.durationMs)}`);
|
|
46
|
+
if (result.environment === "local") Logger.rawLog(ApplicationBuildReporter.localBuildNotice);
|
|
38
47
|
}
|
|
39
48
|
|
|
49
|
+
static readonly localBuildNotice =
|
|
50
|
+
"Built for local: the server runs without endpoint rate limits, and boots on local defaults (Redis on localhost, the first database mode) where a deployment would stop to ask. For a host others can reach, run `akan build <app> --env main` with AKAN_PUBLIC_REPO_NAME and AKAN_PUBLIC_SERVE_DOMAIN set in the shell or the workspace .env.";
|
|
51
|
+
|
|
40
52
|
static formatError(error: unknown, workspaceRoot = process.cwd()): string {
|
|
41
53
|
if (error instanceof AggregateError) {
|
|
42
54
|
const nestedMessages = error.errors
|
|
@@ -159,7 +159,7 @@ console.info(new SampleService().name);
|
|
|
159
159
|
}
|
|
160
160
|
});
|
|
161
161
|
|
|
162
|
-
test("
|
|
162
|
+
test("builds for the shell's AKAN_PUBLIC_ENV over the root .env's, and for --env over both", async () => {
|
|
163
163
|
const { root, workspace } = await createTempApp("portal");
|
|
164
164
|
const shellEnv = process.env.AKAN_PUBLIC_ENV;
|
|
165
165
|
const stoppedBeforePhases = (app: AppExecutor) => {
|
|
@@ -171,17 +171,37 @@ console.info(new SampleService().name);
|
|
|
171
171
|
const bakedEnv = (app: AppExecutor) =>
|
|
172
172
|
(bundleDefine(app, "build", "ssr") as { [key: string]: string })["process.env.AKAN_PUBLIC_ENV"];
|
|
173
173
|
try {
|
|
174
|
+
expect(await readFile(path.join(root, ".env"), "utf8")).toContain("AKAN_PUBLIC_ENV=local\n");
|
|
174
175
|
process.env.AKAN_PUBLIC_ENV = "main";
|
|
175
176
|
const app = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
|
|
176
|
-
|
|
177
|
-
|
|
177
|
+
const warn = spyOn(app.logger, "warn").mockImplementation(() => undefined);
|
|
178
|
+
const runner = new ApplicationBuildRunner(app);
|
|
179
|
+
await expect(runner.build()).rejects.toThrow("stopped before the phases");
|
|
180
|
+
expect(bakedEnv(app)).toBe(JSON.stringify("main"));
|
|
181
|
+
expect(warn.mock.calls.map(([line]) => line)).toEqual([
|
|
182
|
+
"The shell (or a .env.local) outranks the workspace .env: the bundles and the image take AKAN_PUBLIC_ENV=main (.env: local). Unset it there to build what the .env says.",
|
|
183
|
+
]);
|
|
184
|
+
await runner.buildAppMeta();
|
|
185
|
+
expect(JSON.parse(await readFile(path.join(app.dist.cwdPath, "akan.env.json"), "utf8"))).toMatchObject({
|
|
186
|
+
AKAN_PUBLIC_ENV: "main",
|
|
187
|
+
});
|
|
188
|
+
expect(await readFile(path.join(app.dist.cwdPath, "Dockerfile"), "utf8")).toContain("ENV AKAN_PUBLIC_ENV=main\n");
|
|
178
189
|
|
|
179
190
|
process.env.AKAN_PUBLIC_ENV = "main";
|
|
180
191
|
const desktop = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
|
|
192
|
+
const desktopWarn = spyOn(desktop.logger, "warn").mockImplementation(() => undefined);
|
|
181
193
|
await expect(new ApplicationBuildRunner(desktop, { environment: "debug" }).build()).rejects.toThrow(
|
|
182
194
|
"stopped before the phases",
|
|
183
195
|
);
|
|
184
196
|
expect(bakedEnv(desktop)).toBe(JSON.stringify("debug"));
|
|
197
|
+
expect(desktopWarn).not.toHaveBeenCalled();
|
|
198
|
+
|
|
199
|
+
process.env.AKAN_PUBLIC_ENV = "";
|
|
200
|
+
const unset = stoppedBeforePhases(AppExecutor.from(workspace, "portal"));
|
|
201
|
+
await expect(new ApplicationBuildRunner(unset, { environment: "debug" }).build()).rejects.toThrow(
|
|
202
|
+
"stopped before the phases",
|
|
203
|
+
);
|
|
204
|
+
expect(bakedEnv(unset)).toBe(JSON.stringify("debug"));
|
|
185
205
|
} finally {
|
|
186
206
|
if (shellEnv === undefined) delete process.env.AKAN_PUBLIC_ENV;
|
|
187
207
|
else process.env.AKAN_PUBLIC_ENV = shellEnv;
|
|
@@ -189,7 +209,7 @@ console.info(new SampleService().name);
|
|
|
189
209
|
}
|
|
190
210
|
});
|
|
191
211
|
|
|
192
|
-
test("bakes into the bundles every AKAN_PUBLIC_* its Dockerfile carries,
|
|
212
|
+
test("bakes into the bundles every AKAN_PUBLIC_* its Dockerfile carries, each the shell's over the root .env's", async () => {
|
|
193
213
|
const { root, workspace } = await createTempApp("portal");
|
|
194
214
|
const keys = ["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const;
|
|
195
215
|
const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
@@ -198,11 +218,8 @@ console.info(new SampleService().name);
|
|
|
198
218
|
"AKAN_PUBLIC_ENV=main\nAKAN_PUBLIC_REPO_NAME=repo\nAKAN_PUBLIC_SERVE_DOMAIN=akanjs.com\n",
|
|
199
219
|
);
|
|
200
220
|
await Promise.all(["private", "public"].map((dir) => mkdir(path.join(root, "apps", "portal", dir))));
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
AKAN_PUBLIC_REPO_NAME: "shell-repo",
|
|
204
|
-
AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
|
|
205
|
-
});
|
|
221
|
+
for (const key of keys) delete process.env[key];
|
|
222
|
+
process.env.AKAN_PUBLIC_REPO_NAME = "shell-repo";
|
|
206
223
|
const app = AppExecutor.from(workspace, "portal");
|
|
207
224
|
const warn = spyOn(app.logger, "warn").mockImplementation(() => undefined);
|
|
208
225
|
const runner = new ApplicationBuildRunner(app);
|
|
@@ -214,11 +231,14 @@ console.info(new SampleService().name);
|
|
|
214
231
|
const define = bundleDefine(app, "build", "csr") as { [key: string]: string };
|
|
215
232
|
const imageEnv = (await app.getConfig()).getProductionEnv();
|
|
216
233
|
for (const key of keys) expect(define[`process.env.${key}`]).toBe(JSON.stringify(imageEnv[key]));
|
|
217
|
-
expect(imageEnv
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
);
|
|
234
|
+
expect(imageEnv).toMatchObject({
|
|
235
|
+
AKAN_PUBLIC_ENV: "main",
|
|
236
|
+
AKAN_PUBLIC_REPO_NAME: "shell-repo",
|
|
237
|
+
AKAN_PUBLIC_SERVE_DOMAIN: "akanjs.com",
|
|
238
|
+
});
|
|
239
|
+
expect(warn.mock.calls.map(([line]) => line)).toEqual([
|
|
240
|
+
"The shell (or a .env.local) outranks the workspace .env: the bundles and the image take AKAN_PUBLIC_REPO_NAME=shell-repo (.env: repo). Unset it there to build what the .env says.",
|
|
241
|
+
]);
|
|
222
242
|
} finally {
|
|
223
243
|
for (const key of keys) {
|
|
224
244
|
if (shellEnv[key] === undefined) delete process.env[key];
|
|
@@ -9,14 +9,17 @@ import type {
|
|
|
9
9
|
ApplicationBuildResult,
|
|
10
10
|
} from "./applicationBuildReporter";
|
|
11
11
|
import type { App } from "./commandDecorators";
|
|
12
|
-
import { WorkspaceExecutor } from "./executors";
|
|
12
|
+
import { parseEnvFile, WorkspaceExecutor } from "./executors";
|
|
13
13
|
import {
|
|
14
14
|
AllRoutesBuilder,
|
|
15
15
|
CsrArtifactBuilder,
|
|
16
16
|
FontPruner,
|
|
17
|
+
NARROWABLE_VENDOR_SPECIFIERS,
|
|
17
18
|
precompressArtifacts,
|
|
18
19
|
SsrBaseArtifactBuilder,
|
|
20
|
+
VendorImportScanner,
|
|
19
21
|
} from "./frontendBuild";
|
|
22
|
+
import { RouteJsBudget, type RouteJsBudgetMode, type RouteJsBudgetStatus, type RouteJsSize } from "./routeJsBudget";
|
|
20
23
|
import { Spinner } from "./spinner";
|
|
21
24
|
import { createServerEnvPlugin } from "./transforms/serverEnvPlugin";
|
|
22
25
|
import { ServerEnvProfile } from "./transforms/serverEnvProfile";
|
|
@@ -31,7 +34,16 @@ export interface TypecheckOptions {
|
|
|
31
34
|
report?: "pretty" | "records";
|
|
32
35
|
}
|
|
33
36
|
|
|
34
|
-
export type BuildPhaseId =
|
|
37
|
+
export type BuildPhaseId =
|
|
38
|
+
| "prepare"
|
|
39
|
+
| "typecheck"
|
|
40
|
+
| "backend"
|
|
41
|
+
| "ssr"
|
|
42
|
+
| "csr"
|
|
43
|
+
| "assets"
|
|
44
|
+
| "compress"
|
|
45
|
+
| "budget"
|
|
46
|
+
| "metadata";
|
|
35
47
|
|
|
36
48
|
export type BuildPhaseResult = ApplicationBuildPhaseResult & { id: BuildPhaseId };
|
|
37
49
|
export type BuildResult = ApplicationBuildResult;
|
|
@@ -43,6 +55,8 @@ export interface ApplicationBuildRunnerOptions {
|
|
|
43
55
|
environment?: string;
|
|
44
56
|
/** Bundles `env/env.server.<environment>.<profile>.ts`, which may hold no secret, in place of the environment's file. */
|
|
45
57
|
profile?: string;
|
|
58
|
+
/** `update` writes akan.budget.json, then enforces it; `report` names an overage without failing the build. */
|
|
59
|
+
budget?: RouteJsBudgetMode;
|
|
46
60
|
}
|
|
47
61
|
export interface BuildOptions {
|
|
48
62
|
spinner?: boolean;
|
|
@@ -59,6 +73,7 @@ const BUILD_PHASE_EMOJIS: Record<BuildPhaseId, string> = {
|
|
|
59
73
|
csr: "🎨",
|
|
60
74
|
assets: "✂️",
|
|
61
75
|
compress: "🗜️",
|
|
76
|
+
budget: "📏",
|
|
62
77
|
metadata: "📝",
|
|
63
78
|
};
|
|
64
79
|
|
|
@@ -86,39 +101,46 @@ export class ApplicationBuildRunner {
|
|
|
86
101
|
#reporter?: BuildProgressReporter;
|
|
87
102
|
#environment?: string;
|
|
88
103
|
#profile?: string;
|
|
104
|
+
#budget: RouteJsBudgetMode;
|
|
105
|
+
#modulesByRoute?: Record<string, string[]>;
|
|
89
106
|
#spinner?: boolean;
|
|
90
107
|
#startedAt = Date.now();
|
|
91
108
|
#phases: BuildPhaseResult[] = [];
|
|
92
109
|
|
|
93
|
-
constructor(
|
|
110
|
+
constructor(
|
|
111
|
+
app: App,
|
|
112
|
+
{ fast = false, reporter, environment, profile, budget = "report" }: ApplicationBuildRunnerOptions = {},
|
|
113
|
+
) {
|
|
94
114
|
this.#app = app;
|
|
95
115
|
this.#fast = fast;
|
|
96
116
|
this.#reporter = reporter;
|
|
97
117
|
this.#environment = environment;
|
|
98
118
|
this.#profile = profile;
|
|
119
|
+
this.#budget = budget;
|
|
99
120
|
}
|
|
100
121
|
|
|
101
122
|
get #envDir() {
|
|
102
123
|
return path.join(this.#app.cwdPath, "env");
|
|
103
124
|
}
|
|
104
125
|
|
|
105
|
-
//? Bundles bake AKAN_PUBLIC_* from process.env,
|
|
106
|
-
//?
|
|
126
|
+
//? Bundles bake AKAN_PUBLIC_* from process.env, which lacks `--env` and the defaults an unset repo name or serve
|
|
127
|
+
//? domain takes: a page built otherwise drops the image's tokens as foreign or dials another serve domain.
|
|
107
128
|
#pinImageEnv(baseDevEnv: BaseDevEnv, environment: string) {
|
|
108
129
|
const imageEnv = {
|
|
109
130
|
AKAN_PUBLIC_ENV: environment,
|
|
110
131
|
AKAN_PUBLIC_REPO_NAME: baseDevEnv.repoName,
|
|
111
132
|
AKAN_PUBLIC_SERVE_DOMAIN: baseDevEnv.serveDomain,
|
|
112
133
|
};
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
134
|
+
const dotenv = parseEnvFile(path.join(this.#app.workspace.workspaceRoot, ".env"));
|
|
135
|
+
const overridden = Object.entries(imageEnv).filter(([key, value]) => {
|
|
136
|
+
if (!dotenv[key] || dotenv[key] === value || WorkspaceExecutor.publicEnvPin?.[key]) return false;
|
|
137
|
+
return !(key === "AKAN_PUBLIC_ENV" && this.#environment);
|
|
116
138
|
});
|
|
117
|
-
if (
|
|
139
|
+
if (overridden.length)
|
|
118
140
|
this.#app.logger.warn(
|
|
119
|
-
`The
|
|
120
|
-
.map(([key, value]) => `${key}=${value} (
|
|
121
|
-
.join(", ")}.`,
|
|
141
|
+
`The shell (or a .env.local) outranks the workspace .env: the bundles and the image take ${overridden
|
|
142
|
+
.map(([key, value]) => `${key}=${value} (.env: ${dotenv[key]})`)
|
|
143
|
+
.join(", ")}. Unset it there to build what the .env says.`,
|
|
122
144
|
);
|
|
123
145
|
Object.assign(process.env, imageEnv);
|
|
124
146
|
}
|
|
@@ -176,12 +198,21 @@ export class ApplicationBuildRunner {
|
|
|
176
198
|
? `${result.files} files, ${ApplicationBuildRunner.formatBytes(result.inputBytes)} -> gzip ${ApplicationBuildRunner.formatBytes(result.outputBytes)} / br ${ApplicationBuildRunner.formatBytes(result.brotliBytes)}`
|
|
177
199
|
: "no files",
|
|
178
200
|
);
|
|
201
|
+
const routeJs = await this.#runPhase(
|
|
202
|
+
"budget",
|
|
203
|
+
"Measuring route JS",
|
|
204
|
+
() => this.#measureRouteJs(environment),
|
|
205
|
+
(result) => result?.summary ?? (this.#budget === "off" ? "off for this build" : "no server pages"),
|
|
206
|
+
);
|
|
179
207
|
await this.#runPhase("metadata", "Writing production metadata", () => this.buildAppMeta());
|
|
180
208
|
return {
|
|
181
209
|
phases: this.#phases,
|
|
210
|
+
...(routeJs ? { routeJs: routeJs.sizes } : {}),
|
|
211
|
+
...(routeJs?.warnings ? { routeJsWarnings: routeJs.warnings } : {}),
|
|
182
212
|
durationMs: Date.now() - this.#startedAt,
|
|
183
213
|
outputDir: this.#app.dist.cwdPath,
|
|
184
214
|
artifactDir: path.join(this.#app.dist.cwdPath, ".akan/artifact"),
|
|
215
|
+
environment,
|
|
185
216
|
};
|
|
186
217
|
}
|
|
187
218
|
|
|
@@ -292,7 +323,6 @@ export class ApplicationBuildRunner {
|
|
|
292
323
|
#serverEnvPlugin(baseEnv: string, branches: Iterable<string>): BunPlugin {
|
|
293
324
|
this.#serverEnv ??= createServerEnvPlugin({
|
|
294
325
|
envDir: this.#envDir,
|
|
295
|
-
//? The root .env wins over the shell in baseDevEnv, so a `--env` build names its own; a desktop app runs it.
|
|
296
326
|
environment: this.#environment ?? baseEnv,
|
|
297
327
|
environments: ["local", "testing", ...branches],
|
|
298
328
|
profile: this.#profile,
|
|
@@ -401,13 +431,46 @@ void run().catch((error) => {
|
|
|
401
431
|
return null;
|
|
402
432
|
}
|
|
403
433
|
const { baseDevEnv, branches } = await this.#app.getConfig();
|
|
434
|
+
//? Routes first: the vendor entries export only the names the route bundles import.
|
|
435
|
+
const routes = new AllRoutesBuilder(this.#app, "build");
|
|
436
|
+
const allRoutes = await routes.build();
|
|
437
|
+
const vendorExports = await VendorImportScanner.exportsUsedIn(
|
|
438
|
+
path.join(this.#app.dist.cwdPath, ".akan/artifact/client"),
|
|
439
|
+
NARROWABLE_VENDOR_SPECIFIERS,
|
|
440
|
+
);
|
|
404
441
|
const base = await new SsrBaseArtifactBuilder(this.#app, "build", {
|
|
405
442
|
serverEnv: this.#serverEnvPlugin(baseDevEnv.env, branches),
|
|
443
|
+
vendorExports,
|
|
406
444
|
}).build();
|
|
407
|
-
|
|
445
|
+
this.#modulesByRoute = (await routes.writeModulePreloads(base.runtimeChunks)).modulesByRoute();
|
|
408
446
|
return { base, allRoutes };
|
|
409
447
|
}
|
|
410
448
|
|
|
449
|
+
//? After `compress`: a route is measured by the brotli bytes it is served as, read from the `.br` sidecars.
|
|
450
|
+
async #measureRouteJs(environment: string): Promise<(RouteJsBudgetStatus & { sizes: RouteJsSize[] }) | null> {
|
|
451
|
+
if (this.#budget === "off" || !this.#modulesByRoute) return null;
|
|
452
|
+
const sizes = await RouteJsBudget.measure(
|
|
453
|
+
path.join(this.#app.dist.cwdPath, ".akan/artifact"),
|
|
454
|
+
this.#modulesByRoute,
|
|
455
|
+
);
|
|
456
|
+
const budgetPath = path.join(this.#app.cwdPath, RouteJsBudget.fileName);
|
|
457
|
+
const { summary: status, warnings } = await RouteJsBudget.check(sizes, {
|
|
458
|
+
mode: this.#budget,
|
|
459
|
+
budgetPath,
|
|
460
|
+
source: path.relative(this.#app.workspace.workspaceRoot, budgetPath),
|
|
461
|
+
environment,
|
|
462
|
+
});
|
|
463
|
+
const largest = sizes[0];
|
|
464
|
+
const { formatBytes } = ApplicationBuildRunner;
|
|
465
|
+
return {
|
|
466
|
+
sizes,
|
|
467
|
+
summary: largest
|
|
468
|
+
? `${sizes.length} routes, largest ${largest.routeId} ${formatBytes(largest.brotliBytes)} brotli (${formatBytes(largest.rawBytes)} raw); ${status}`
|
|
469
|
+
: status,
|
|
470
|
+
...(warnings ? { warnings } : {}),
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
411
474
|
//? Opt-in and outside `dist/`: a metafile is the size of the module graph, and `dist/` is what the image copies.
|
|
412
475
|
async #buildOrThrow(label: string, config: Bun.BuildConfig): Promise<Bun.BuildOutput> {
|
|
413
476
|
const flag = process.env.AKAN_BUILD_METAFILE;
|
|
@@ -101,6 +101,48 @@ describe("resolveSsrPageEntries", () => {
|
|
|
101
101
|
expect(generatedDictMacro).toContain("export const allDictionary = getAllDictionary();");
|
|
102
102
|
});
|
|
103
103
|
|
|
104
|
+
test("bootstraps the client runtime and store, not the client barrel, at every root boundary", async () => {
|
|
105
|
+
const appRoot = await makeTempRoot();
|
|
106
|
+
const pageRoot = path.join(appRoot, "page");
|
|
107
|
+
|
|
108
|
+
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
109
|
+
await write(path.join(appRoot, "lib", "st.ts"), "export const st = {};\n");
|
|
110
|
+
await write(path.join(pageRoot, "(home)", "_layout.tsx"), 'export const theme = "dark";\n');
|
|
111
|
+
await write(path.join(pageRoot, "(admin)", "_layout.tsx"), 'export const theme = "light";\n');
|
|
112
|
+
|
|
113
|
+
const entries = await resolveSsrPageEntries({
|
|
114
|
+
appCwdPath: appRoot,
|
|
115
|
+
appName: "demo",
|
|
116
|
+
pageKeys: ["./(home)/_layout.tsx", "./(home)/_index.tsx", "./(admin)/_layout.tsx", "./(admin)/users.tsx"],
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
const roots = entries.filter((entry) => entry.key.endsWith("__root_layout.tsx"));
|
|
120
|
+
expect(roots.map((entry) => entry.key)).toEqual(["./(admin)/__root_layout.tsx", "./(home)/__root_layout.tsx"]);
|
|
121
|
+
for (const root of roots) {
|
|
122
|
+
const generatedSource = await Bun.file(root.moduleAbsPath).text();
|
|
123
|
+
expect(generatedSource).toContain('import "@apps/demo/lib/useClient";\nimport "@apps/demo/lib/st";\n');
|
|
124
|
+
expect(generatedSource).not.toContain("@apps/demo/client");
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test("imports no store bootstrap for an app without a store", async () => {
|
|
129
|
+
const appRoot = await makeTempRoot();
|
|
130
|
+
await write(path.join(appRoot, "env", "env.client.ts"), "export const env = {};\n");
|
|
131
|
+
await write(path.join(appRoot, "page", "_layout.tsx"), 'export const theme = "dark";\n');
|
|
132
|
+
|
|
133
|
+
const entries = await resolveSsrPageEntries({
|
|
134
|
+
appCwdPath: appRoot,
|
|
135
|
+
appName: "demo",
|
|
136
|
+
pageKeys: ["./_layout.tsx", "./_index.tsx"],
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
const root = entries.find((entry) => entry.key === "./__root_layout.tsx");
|
|
140
|
+
const generatedSource = await Bun.file(root?.moduleAbsPath ?? "").text();
|
|
141
|
+
expect(generatedSource).toContain("<System.Provider");
|
|
142
|
+
expect(generatedSource).not.toContain("@apps/demo/lib/");
|
|
143
|
+
expect(generatedSource).not.toContain("@apps/demo/client");
|
|
144
|
+
});
|
|
145
|
+
|
|
104
146
|
test("awaits an async grouped root layout in the CSR bundle instead of mounting it as JSX", async () => {
|
|
105
147
|
const appRoot = await makeTempRoot();
|
|
106
148
|
const pageRoot = path.join(appRoot, "page");
|