@akanjs/devkit 3.2.0-rc.5 → 3.2.0-rc.6
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 +17 -6
- package/apiReport/apiDeclarationPrinter.ts +120 -0
- package/apiReport/apiReport.test.ts +483 -0
- package/apiReport/apiReport.ts +232 -0
- package/apiReport/apiReportDiff.ts +126 -0
- package/apiReport/apiReportPolicy.ts +64 -0
- package/apiReport/apiTargets.ts +311 -0
- package/apiReport/changesetBump.ts +19 -0
- package/applicationBuildReporter.test.ts +21 -0
- package/applicationBuildReporter.ts +8 -0
- package/applicationBuildRunner.ts +5 -1
- package/artifact/implicitRootLayout.ts +12 -1
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/clientBuildTypes.ts +5 -1
- package/frontendBuild/clientEntryDiscovery.test.ts +28 -0
- package/frontendBuild/clientEntryDiscovery.ts +25 -6
- package/package.json +3 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
import { ApiDeclarationPrinter } from "./apiDeclarationPrinter";
|
|
3
|
+
|
|
4
|
+
export interface ApiTarget {
|
|
5
|
+
id: string;
|
|
6
|
+
name: string;
|
|
7
|
+
origin: string;
|
|
8
|
+
file: ts.SourceFile | null;
|
|
9
|
+
statements: ts.Statement[];
|
|
10
|
+
thirdParty: string | null;
|
|
11
|
+
members: ts.Symbol[] | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface ImportBinding {
|
|
15
|
+
specifier: string;
|
|
16
|
+
imported: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
//* Statements come from the emitted `.d.ts`, which also holds what the emit adds, such as a mixin class's `X_base`.
|
|
20
|
+
export class ApiTargets {
|
|
21
|
+
readonly #program: ts.Program;
|
|
22
|
+
readonly #checker: ts.TypeChecker;
|
|
23
|
+
readonly #emitted: ReadonlyMap<string, string>;
|
|
24
|
+
readonly #parsed = new Map<string, ts.SourceFile>();
|
|
25
|
+
readonly #indexes = new Map<ts.SourceFile, Map<string, ts.Statement[]>>();
|
|
26
|
+
readonly #imports = new Map<ts.SourceFile, Map<string, ImportBinding>>();
|
|
27
|
+
readonly #modules = new Map<string, ts.SourceFile | null>();
|
|
28
|
+
readonly #moduleExports = new Map<ts.SourceFile, Map<string, ts.Symbol>>();
|
|
29
|
+
readonly #references = new Map<string, ApiTarget[]>();
|
|
30
|
+
|
|
31
|
+
constructor(program: ts.Program, emitted: ReadonlyMap<string, string>) {
|
|
32
|
+
this.#program = program;
|
|
33
|
+
this.#checker = program.getTypeChecker();
|
|
34
|
+
this.#emitted = emitted;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
exportsOf(sourceFile: ts.SourceFile) {
|
|
38
|
+
const moduleSymbol = this.#checker.getSymbolAtLocation(sourceFile);
|
|
39
|
+
return moduleSymbol ? this.#checker.getExportsOfModule(moduleSymbol) : [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
typeTextOf(symbol: ts.Symbol) {
|
|
43
|
+
const target = symbol.flags & ts.SymbolFlags.Alias ? this.#checker.getAliasedSymbol(symbol) : symbol;
|
|
44
|
+
return this.#checker.typeToString(
|
|
45
|
+
this.#checker.getTypeOfSymbol(target),
|
|
46
|
+
undefined,
|
|
47
|
+
ts.TypeFormatFlags.NoTruncation,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
ofSymbol(symbol: ts.Symbol): ApiTarget[] {
|
|
52
|
+
const target = symbol.flags & ts.SymbolFlags.Alias ? this.#checker.getAliasedSymbol(symbol) : symbol;
|
|
53
|
+
const targets = new Map<string, ApiTarget>();
|
|
54
|
+
for (const declaration of target.declarations ?? []) {
|
|
55
|
+
const resolved = this.#ofDeclaration(declaration);
|
|
56
|
+
if (resolved && !targets.has(resolved.id)) targets.set(resolved.id, resolved);
|
|
57
|
+
}
|
|
58
|
+
return [...targets.values()];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
expand(targets: ApiTarget[], seen = new Set<string>()): ApiTarget[] {
|
|
62
|
+
return targets.flatMap((target) => {
|
|
63
|
+
if (!target.members) return [target];
|
|
64
|
+
if (seen.has(target.id)) return [];
|
|
65
|
+
seen.add(target.id);
|
|
66
|
+
return this.expand(
|
|
67
|
+
target.members.flatMap((member) => this.ofSymbol(member)),
|
|
68
|
+
seen,
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
referencesOf(target: ApiTarget): ApiTarget[] {
|
|
74
|
+
const cached = this.#references.get(target.id);
|
|
75
|
+
if (cached) return cached;
|
|
76
|
+
const file = target.file;
|
|
77
|
+
const found = file
|
|
78
|
+
? target.statements.flatMap((statement) =>
|
|
79
|
+
this.#referencesIn(ApiDeclarationPrinter.prepared(statement), file, target.origin),
|
|
80
|
+
)
|
|
81
|
+
: [];
|
|
82
|
+
const references = this.expand(found);
|
|
83
|
+
this.#references.set(target.id, references);
|
|
84
|
+
return references;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
#ofDeclaration(declaration: ts.Declaration): ApiTarget | null {
|
|
88
|
+
if (ts.isSourceFile(declaration)) {
|
|
89
|
+
return {
|
|
90
|
+
id: `${declaration.fileName}\0*`,
|
|
91
|
+
name: "*",
|
|
92
|
+
origin: declaration.fileName,
|
|
93
|
+
file: null,
|
|
94
|
+
statements: [],
|
|
95
|
+
thirdParty: ApiTargets.packageOf(declaration.fileName),
|
|
96
|
+
members: this.exportsOf(declaration),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
const sourceFile = declaration.getSourceFile();
|
|
100
|
+
const name = ApiTargets.localNameOf(declaration);
|
|
101
|
+
if (name === null) return null;
|
|
102
|
+
const thirdParty = ApiTargets.packageOf(sourceFile.fileName);
|
|
103
|
+
const base = { id: `${sourceFile.fileName}\0${name}`, name, origin: sourceFile.fileName, members: null };
|
|
104
|
+
if (thirdParty) return { ...base, file: null, statements: [], thirdParty };
|
|
105
|
+
const file = sourceFile.isDeclarationFile ? sourceFile : this.#declarationFileOf(sourceFile);
|
|
106
|
+
if (!file) return { ...base, file: null, statements: [], thirdParty: null };
|
|
107
|
+
const statements = this.#indexOf(file).get(name);
|
|
108
|
+
if (statements) return { ...base, file, statements, thirdParty: null };
|
|
109
|
+
const nested = ApiTargets.#statementOf(declaration);
|
|
110
|
+
return {
|
|
111
|
+
...base,
|
|
112
|
+
id: `${base.id}\0${declaration.pos}`,
|
|
113
|
+
file,
|
|
114
|
+
statements: nested && sourceFile.isDeclarationFile ? [nested] : [],
|
|
115
|
+
thirdParty: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#referencesIn(root: ts.Node, file: ts.SourceFile, origin: string): ApiTarget[] {
|
|
120
|
+
const found: ApiTarget[] = [];
|
|
121
|
+
const visit = (node: ts.Node) => {
|
|
122
|
+
if (ts.isTypeReferenceNode(node))
|
|
123
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.typeName), node, file, origin));
|
|
124
|
+
else if (ts.isExpressionWithTypeArguments(node))
|
|
125
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.expression), node, file, origin));
|
|
126
|
+
else if (ts.isTypeQueryNode(node))
|
|
127
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.exprName), node, file, origin));
|
|
128
|
+
else if (ts.isComputedPropertyName(node))
|
|
129
|
+
found.push(...this.#resolve(ApiTargets.#segmentsOf(node.expression), node, file, origin));
|
|
130
|
+
else if (ts.isImportTypeNode(node)) found.push(...this.#resolveImportType(node, origin));
|
|
131
|
+
ts.forEachChild(node, visit);
|
|
132
|
+
};
|
|
133
|
+
visit(root);
|
|
134
|
+
return found;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
#resolve(segments: string[] | null, at: ts.Node, file: ts.SourceFile, origin: string): ApiTarget[] {
|
|
138
|
+
const [head, next] = segments ?? [];
|
|
139
|
+
if (!head || ApiTargets.#isTypeParameter(at, head)) return [];
|
|
140
|
+
const local = this.#indexOf(file).get(head);
|
|
141
|
+
if (local) return [this.#local(file, origin, head, local)];
|
|
142
|
+
const binding = this.#importsOf(file).get(head);
|
|
143
|
+
if (!binding) return [];
|
|
144
|
+
const module = this.#moduleOf(binding.specifier, origin);
|
|
145
|
+
if (!module) return [];
|
|
146
|
+
if (binding.imported !== "*") return this.#exported(module, binding.imported);
|
|
147
|
+
return next ? this.#exported(module, next) : this.#namespaceOf(module);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
#resolveImportType(node: ts.ImportTypeNode, origin: string): ApiTarget[] {
|
|
151
|
+
const argument = node.argument;
|
|
152
|
+
if (!ts.isLiteralTypeNode(argument) || !ts.isStringLiteral(argument.literal)) return [];
|
|
153
|
+
const module = this.#moduleOf(argument.literal.text, origin);
|
|
154
|
+
if (!module) return [];
|
|
155
|
+
const [name] = node.qualifier ? (ApiTargets.#segmentsOf(node.qualifier) ?? []) : [];
|
|
156
|
+
return name ? this.#exported(module, name) : this.#namespaceOf(module);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
#local(file: ts.SourceFile, origin: string, name: string, statements: ts.Statement[]): ApiTarget {
|
|
160
|
+
return { id: `${origin}\0${name}`, name, origin, file, statements, thirdParty: null, members: null };
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
#exported(module: ts.SourceFile, name: string) {
|
|
164
|
+
const symbol = this.#moduleExportsOf(module).get(name);
|
|
165
|
+
return symbol ? this.ofSymbol(symbol) : [];
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
#namespaceOf(module: ts.SourceFile): ApiTarget[] {
|
|
169
|
+
return [
|
|
170
|
+
{
|
|
171
|
+
id: `${module.fileName}\0*`,
|
|
172
|
+
name: "*",
|
|
173
|
+
origin: module.fileName,
|
|
174
|
+
file: null,
|
|
175
|
+
statements: [],
|
|
176
|
+
thirdParty: null,
|
|
177
|
+
members: [...this.#moduleExportsOf(module).values()],
|
|
178
|
+
},
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
#moduleExportsOf(module: ts.SourceFile) {
|
|
183
|
+
const cached = this.#moduleExports.get(module);
|
|
184
|
+
if (cached) return cached;
|
|
185
|
+
const exports = new Map(this.exportsOf(module).map((symbol) => [symbol.getName(), symbol]));
|
|
186
|
+
this.#moduleExports.set(module, exports);
|
|
187
|
+
return exports;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
#moduleOf(specifier: string, origin: string) {
|
|
191
|
+
const key = `${origin}\0${specifier}`;
|
|
192
|
+
if (this.#modules.has(key)) return this.#modules.get(key) ?? null;
|
|
193
|
+
const resolved = ts.resolveModuleName(specifier, origin, this.#program.getCompilerOptions(), ts.sys).resolvedModule;
|
|
194
|
+
const fileName = resolved?.resolvedFileName;
|
|
195
|
+
const module = fileName && !ApiTargets.packageOf(fileName) ? (this.#program.getSourceFile(fileName) ?? null) : null;
|
|
196
|
+
this.#modules.set(key, module);
|
|
197
|
+
return module;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
#declarationFileOf(sourceFile: ts.SourceFile) {
|
|
201
|
+
const cached = this.#parsed.get(sourceFile.fileName);
|
|
202
|
+
if (cached) return cached;
|
|
203
|
+
const text = this.#emitted.get(sourceFile.fileName);
|
|
204
|
+
if (text === undefined) return null;
|
|
205
|
+
const fileName = sourceFile.fileName.replace(/\.tsx?$/, ".d.ts");
|
|
206
|
+
const parsed = ts.createSourceFile(fileName, text, ts.ScriptTarget.Latest, true);
|
|
207
|
+
this.#parsed.set(sourceFile.fileName, parsed);
|
|
208
|
+
return parsed;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
#indexOf(file: ts.SourceFile) {
|
|
212
|
+
const cached = this.#indexes.get(file);
|
|
213
|
+
if (cached) return cached;
|
|
214
|
+
const index = new Map<string, ts.Statement[]>();
|
|
215
|
+
const add = (name: string, statement: ts.Statement) => index.set(name, [...(index.get(name) ?? []), statement]);
|
|
216
|
+
for (const statement of file.statements) {
|
|
217
|
+
if (ts.isVariableStatement(statement)) {
|
|
218
|
+
for (const declaration of statement.declarationList.declarations)
|
|
219
|
+
if (ts.isIdentifier(declaration.name))
|
|
220
|
+
add(declaration.name.text, ApiTargets.#narrowed(statement, declaration));
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
const name = ApiDeclarationPrinter.isNamed(statement) ? ApiTargets.localNameOf(statement) : null;
|
|
224
|
+
if (name !== null) add(name, statement);
|
|
225
|
+
}
|
|
226
|
+
this.#indexes.set(file, index);
|
|
227
|
+
return index;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
#importsOf(file: ts.SourceFile) {
|
|
231
|
+
const cached = this.#imports.get(file);
|
|
232
|
+
if (cached) return cached;
|
|
233
|
+
const bindings = new Map<string, ImportBinding>();
|
|
234
|
+
for (const statement of file.statements) {
|
|
235
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier)) continue;
|
|
236
|
+
const specifier = statement.moduleSpecifier.text;
|
|
237
|
+
const clause = statement.importClause;
|
|
238
|
+
if (clause?.name) bindings.set(clause.name.text, { specifier, imported: "default" });
|
|
239
|
+
const named = clause?.namedBindings;
|
|
240
|
+
if (named && ts.isNamespaceImport(named)) bindings.set(named.name.text, { specifier, imported: "*" });
|
|
241
|
+
if (named && ts.isNamedImports(named))
|
|
242
|
+
for (const element of named.elements)
|
|
243
|
+
bindings.set(element.name.text, { specifier, imported: (element.propertyName ?? element.name).text });
|
|
244
|
+
}
|
|
245
|
+
this.#imports.set(file, bindings);
|
|
246
|
+
return bindings;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
static localNameOf(declaration: ts.Declaration): string | null {
|
|
250
|
+
const name = ts.getNameOfDeclaration(declaration);
|
|
251
|
+
if (name && ts.isIdentifier(name)) return name.text;
|
|
252
|
+
if (name) return null;
|
|
253
|
+
const modifiers = ts.canHaveModifiers(declaration) ? ts.getModifiers(declaration) : undefined;
|
|
254
|
+
return modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.DefaultKeyword) ? "default" : null;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
static packageOf(fileName: string) {
|
|
258
|
+
const normalized = fileName.replaceAll("\\", "/");
|
|
259
|
+
const idx = normalized.lastIndexOf("/node_modules/");
|
|
260
|
+
if (idx < 0) return null;
|
|
261
|
+
const [scope = "", name = ""] = normalized.slice(idx + "/node_modules/".length).split("/");
|
|
262
|
+
return scope.startsWith("@") ? `${scope}/${name}` : scope;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
static #statementOf(declaration: ts.Declaration): ts.Statement | null {
|
|
266
|
+
if (ts.isVariableDeclaration(declaration)) {
|
|
267
|
+
const statement = declaration.parent.parent;
|
|
268
|
+
return ts.isVariableStatement(statement) ? ApiTargets.#narrowed(statement, declaration) : null;
|
|
269
|
+
}
|
|
270
|
+
return ApiDeclarationPrinter.isNamed(declaration) ? declaration : null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
static #narrowed(statement: ts.VariableStatement, declaration: ts.VariableDeclaration) {
|
|
274
|
+
if (statement.declarationList.declarations.length === 1) return statement;
|
|
275
|
+
const narrowed = ts.factory.updateVariableStatement(
|
|
276
|
+
statement,
|
|
277
|
+
statement.modifiers,
|
|
278
|
+
ts.factory.updateVariableDeclarationList(statement.declarationList, [declaration]),
|
|
279
|
+
);
|
|
280
|
+
ts.setOriginalNode(narrowed, statement);
|
|
281
|
+
return narrowed;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
static #segmentsOf(node: ts.Node): string[] | null {
|
|
285
|
+
if (ts.isIdentifier(node)) return [node.text];
|
|
286
|
+
if (ts.isQualifiedName(node)) {
|
|
287
|
+
const left = ApiTargets.#segmentsOf(node.left);
|
|
288
|
+
return left && [...left, node.right.text];
|
|
289
|
+
}
|
|
290
|
+
if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name)) {
|
|
291
|
+
const left = ApiTargets.#segmentsOf(node.expression);
|
|
292
|
+
return left && [...left, node.name.text];
|
|
293
|
+
}
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
static #isTypeParameter(at: ts.Node, name: string) {
|
|
298
|
+
for (let node = at.parent; node && !ts.isSourceFile(node); node = node.parent) {
|
|
299
|
+
const typeParameters = (node as { typeParameters?: ts.NodeArray<ts.TypeParameterDeclaration> }).typeParameters;
|
|
300
|
+
if (typeParameters?.some((parameter) => parameter.name.text === name)) return true;
|
|
301
|
+
if (ts.isMappedTypeNode(node) && node.typeParameter.name.text === name) return true;
|
|
302
|
+
if (ts.isConditionalTypeNode(node) && ApiTargets.#infers(node.extendsType, name)) return true;
|
|
303
|
+
}
|
|
304
|
+
return false;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
static #infers(node: ts.Node, name: string): boolean {
|
|
308
|
+
if (ts.isInferTypeNode(node) && node.typeParameter.name.text === name) return true;
|
|
309
|
+
return ts.forEachChild(node, (child) => ApiTargets.#infers(child, name) || undefined) ?? false;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export type Bump = "major" | "minor" | "patch";
|
|
2
|
+
|
|
3
|
+
export class ChangesetBump {
|
|
4
|
+
static readonly #rank: Record<Bump, number> = { patch: 0, minor: 1, major: 2 };
|
|
5
|
+
static readonly #entry = /^\s*(["']?)([^"'#:\s][^"'#:]*?)\1\s*:\s*(["']?)(major|minor|patch)\3\s*(?:#.*)?$/;
|
|
6
|
+
|
|
7
|
+
static of(text: string, packageName: string): Bump | null {
|
|
8
|
+
const frontmatter = /^---\r?\n([\s\S]*?)\r?\n---/.exec(text.replace(/^/, ""))?.[1] ?? "";
|
|
9
|
+
for (const line of frontmatter.split(/\r?\n/)) {
|
|
10
|
+
const match = ChangesetBump.#entry.exec(line);
|
|
11
|
+
if (match?.[2] === packageName) return match[4] as Bump;
|
|
12
|
+
}
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
static atLeast(bump: Bump | null, floor: Bump) {
|
|
17
|
+
return bump !== null && ChangesetBump.#rank[bump] >= ChangesetBump.#rank[floor];
|
|
18
|
+
}
|
|
19
|
+
}
|
|
@@ -51,4 +51,25 @@ describe("ApplicationBuildReporter.printSummary", () => {
|
|
|
51
51
|
lines.indexOf("Route artifacts: dist/apps/shop/.akan/artifact"),
|
|
52
52
|
);
|
|
53
53
|
});
|
|
54
|
+
|
|
55
|
+
test("prints only the route JS budget warnings for a quiet build", () => {
|
|
56
|
+
const routeJsWarnings = [{ file: "apps/shop/akan.budget.json", message: "unreadable", fix: "correct it" }];
|
|
57
|
+
ApplicationBuildReporter.printRouteJsWarnings({ routeJsWarnings });
|
|
58
|
+
ApplicationBuildReporter.printRouteJsWarnings({});
|
|
59
|
+
|
|
60
|
+
expect(rawLog.mock.calls.map(([line]) => line)).toEqual([
|
|
61
|
+
"Warning: apps/shop/akan.budget.json: unreadable (correct it)",
|
|
62
|
+
]);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("prints each route JS budget warning of a build that only reports it", () => {
|
|
66
|
+
const routeJsWarnings = [
|
|
67
|
+
{ file: "apps/shop/akan.budget.json", message: "/:lang/memo loads 133,120 bytes brotli", fix: "shrink it" },
|
|
68
|
+
];
|
|
69
|
+
ApplicationBuildReporter.printSummary({ ...resultFor("main"), routeJsWarnings });
|
|
70
|
+
|
|
71
|
+
expect(rawLog.mock.calls.map(([line]) => line)).toContain(
|
|
72
|
+
"Warning: apps/shop/akan.budget.json: /:lang/memo loads 133,120 bytes brotli (shrink it)",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
54
75
|
});
|
|
@@ -40,12 +40,20 @@ export class ApplicationBuildReporter {
|
|
|
40
40
|
for (const line of RouteJsBudget.formatTable(result.routeJs)) Logger.rawLog(line);
|
|
41
41
|
Logger.rawLog("");
|
|
42
42
|
}
|
|
43
|
+
if (result.routeJsWarnings?.length) {
|
|
44
|
+
ApplicationBuildReporter.printRouteJsWarnings(result);
|
|
45
|
+
Logger.rawLog("");
|
|
46
|
+
}
|
|
43
47
|
Logger.rawLog(`Route artifacts: ${result.artifactDir}`);
|
|
44
48
|
Logger.rawLog(`Server output: ${result.outputDir}`);
|
|
45
49
|
Logger.rawLog(`Done in ${ApplicationBuildReporter.formatDuration(result.durationMs)}`);
|
|
46
50
|
if (result.environment === "local") Logger.rawLog(ApplicationBuildReporter.localBuildNotice);
|
|
47
51
|
}
|
|
48
52
|
|
|
53
|
+
static printRouteJsWarnings({ routeJsWarnings = [] }: Pick<ApplicationBuildResult, "routeJsWarnings">) {
|
|
54
|
+
for (const { file, message, fix } of routeJsWarnings) Logger.rawLog(`Warning: ${file}: ${message} (${fix})`);
|
|
55
|
+
}
|
|
56
|
+
|
|
49
57
|
static readonly localBuildNotice =
|
|
50
58
|
"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
59
|
|
|
@@ -55,7 +55,10 @@ export interface ApplicationBuildRunnerOptions {
|
|
|
55
55
|
environment?: string;
|
|
56
56
|
/** Bundles `env/env.server.<environment>.<profile>.ts`, which may hold no secret, in place of the environment's file. */
|
|
57
57
|
profile?: string;
|
|
58
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* `enforce` fails on an overage, a route with no entry, or a missing or unreadable akan.budget.json; `update` and
|
|
60
|
+
* `raise` rewrite the file first; `report` only warns.
|
|
61
|
+
*/
|
|
59
62
|
budget?: RouteJsBudgetMode;
|
|
60
63
|
}
|
|
61
64
|
export interface BuildOptions {
|
|
@@ -457,6 +460,7 @@ void run().catch((error) => {
|
|
|
457
460
|
const budgetPath = path.join(this.#app.cwdPath, RouteJsBudget.fileName);
|
|
458
461
|
const { summary: status, warnings } = await RouteJsBudget.check(sizes, {
|
|
459
462
|
mode: this.#budget,
|
|
463
|
+
app: this.#app.name,
|
|
460
464
|
budgetPath,
|
|
461
465
|
source: path.relative(this.#app.workspace.workspaceRoot, budgetPath),
|
|
462
466
|
environment,
|
|
@@ -71,10 +71,21 @@ export function getRootBoundarySegments(key: string): string[] | null {
|
|
|
71
71
|
return prefix.split("/").filter(Boolean);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
//? The generated root layout serves SSR, CSR and MCP App views alike; an SSR page renders only System's SSR shell.
|
|
75
|
+
export const ROOT_LAYOUT_SYSTEM = {
|
|
76
|
+
specifier: "akanjs/ui/System/index.tsx",
|
|
77
|
+
ssrShell: "akanjs/ui/System/SSR.tsx",
|
|
78
|
+
importsNoSsrPageRenders: ["./CSR", "./App"],
|
|
79
|
+
} as const;
|
|
80
|
+
|
|
74
81
|
function implicitRootLayoutKey(segments: string[]): string {
|
|
75
82
|
return `./${[...segments, "__root_layout"].join("/")}.tsx`;
|
|
76
83
|
}
|
|
77
84
|
|
|
85
|
+
export function isImplicitRootLayoutKey(key: string): boolean {
|
|
86
|
+
return key.endsWith("/__root_layout.tsx");
|
|
87
|
+
}
|
|
88
|
+
|
|
78
89
|
function implicitRootLayoutAbsPath(appCwdPath: string, segments: string[]): string {
|
|
79
90
|
const filename = segments.length ? `${segments.join("__")}__root_layout.tsx` : "__root_layout.tsx";
|
|
80
91
|
return path.join(path.resolve(appCwdPath), IMPLICIT_LAYOUT_DIR, filename);
|
|
@@ -218,7 +229,7 @@ ${layoutBinding} return `;
|
|
|
218
229
|
? `import type { LayoutModule, LayoutProps, PageProps } from "akanjs/client";
|
|
219
230
|
import { assertGuardOnChain, loadFonts, RouteGuards, resolveRouteModule, RouteHead } from "akanjs/client";
|
|
220
231
|
import type { ReactNode } from "react";
|
|
221
|
-
import { System } from "
|
|
232
|
+
import { System } from "${ROOT_LAYOUT_SYSTEM.specifier}";
|
|
222
233
|
import type { McpAppRootProps } from "akanjs/webkit";
|
|
223
234
|
import { env } from "@apps/${opts.appName}/env/env.client";
|
|
224
235
|
import { allDictionary } from ${JSON.stringify(dictMacroSpecifier)};
|
|
@@ -32,14 +32,18 @@ const writeApp = async () => {
|
|
|
32
32
|
);
|
|
33
33
|
await write(
|
|
34
34
|
path.join(appDir, "page/memo.tsx"),
|
|
35
|
-
'import { Memo } from "../ui/Memo";\nexport default function Page() {\n return
|
|
35
|
+
'import { System } from "akanjs/ui/System";\nimport { Memo } from "../ui/Memo";\nexport default function Page() {\n return <><Memo /><System.ThemeToggle /></>;\n}\n',
|
|
36
|
+
);
|
|
37
|
+
await write(
|
|
38
|
+
path.join(appDir, "page/shell.tsx"),
|
|
39
|
+
'import { CSR } from "akanjs/ui/System/CSR";\nexport default function Page() {\n return <CSR />;\n}\n',
|
|
36
40
|
);
|
|
37
41
|
const app = {
|
|
38
42
|
name: "demo",
|
|
39
43
|
cwdPath: appDir,
|
|
40
44
|
dist: { cwdPath: path.join(root, "dist/apps/demo") },
|
|
41
45
|
workspace: { workspaceRoot: root },
|
|
42
|
-
getPageKeys: async () => ["./_index.tsx", "./memo.tsx"],
|
|
46
|
+
getPageKeys: async () => ["./_index.tsx", "./memo.tsx", "./shell.tsx"],
|
|
43
47
|
getConfig: async () => ({ basePaths: [], optimizeImports: [], barrelImports: [] }),
|
|
44
48
|
getTsConfig: async () => ({ compilerOptions: { paths } }),
|
|
45
49
|
getPublicEnv: () => ({}),
|
|
@@ -51,12 +55,12 @@ const writeApp = async () => {
|
|
|
51
55
|
};
|
|
52
56
|
|
|
53
57
|
describe("AllRoutesBuilder", () => {
|
|
54
|
-
test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules", async () => {
|
|
58
|
+
test("writes the route manifest, then the preload table once the runtime chunks are known, and each route's modules without System's CSR and MCP App providers unless the page imports one", async () => {
|
|
55
59
|
const app = await writeApp();
|
|
56
60
|
const routes = new AllRoutesBuilder(app as never, "build");
|
|
57
61
|
const { manifest, manifestPath } = await routes.build();
|
|
58
62
|
|
|
59
|
-
expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo"]));
|
|
63
|
+
expect(new Set(manifest.routeIds)).toEqual(new Set(["/:lang", "/:lang/memo", "/:lang/shell"]));
|
|
60
64
|
expect(await Bun.file(manifestPath).json()).not.toHaveProperty("modulePreloads");
|
|
61
65
|
|
|
62
66
|
const runtimeChunks = { runtime: ["/_akan/client/rsc.js"], vendors: { react: ["/_akan/client/react.js"] } };
|
|
@@ -70,8 +74,14 @@ describe("AllRoutesBuilder", () => {
|
|
|
70
74
|
const [home] = chunksOf("apps/demo/ui/Home.tsx#Home");
|
|
71
75
|
const [memo] = chunksOf("apps/demo/ui/Memo.tsx#Memo");
|
|
72
76
|
const shell = chunksOf("akanjs/ui/System/Client.tsx#Client");
|
|
77
|
+
const [csrShell] = chunksOf("akanjs/ui/System/CSR.tsx#CSR");
|
|
78
|
+
const [mcpAppShell] = chunksOf("akanjs/ui/System/App.tsx#AppProvider");
|
|
79
|
+
const [themeToggle] = chunksOf("akanjs/ui/System/ThemeToggle.tsx#ThemeToggle");
|
|
73
80
|
const modules = preloads.modulesByRoute();
|
|
74
81
|
expect(shell.length).toBeGreaterThan(0);
|
|
82
|
+
expect([csrShell, mcpAppShell, themeToggle]).not.toContain(undefined);
|
|
83
|
+
expect(modules["/:lang/memo"]).toContain(themeToggle);
|
|
84
|
+
expect(modules["/:lang/shell"]).toContain(csrShell);
|
|
75
85
|
for (const [routeId, own, other] of [
|
|
76
86
|
["/:lang", home, memo],
|
|
77
87
|
["/:lang/memo", memo, home],
|
|
@@ -79,6 +89,8 @@ describe("AllRoutesBuilder", () => {
|
|
|
79
89
|
expect(modules[routeId]?.slice(0, 2)).toEqual(["/_akan/client/rsc.js", "/_akan/client/react.js"]);
|
|
80
90
|
expect(modules[routeId]).toEqual(expect.arrayContaining([own, ...shell]));
|
|
81
91
|
expect(modules[routeId]).not.toContain(other);
|
|
92
|
+
expect(modules[routeId]).not.toContain(csrShell);
|
|
93
|
+
expect(modules[routeId]).not.toContain(mcpAppShell);
|
|
82
94
|
}
|
|
83
95
|
}, 30_000);
|
|
84
96
|
});
|
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import type { RoutesManifest } from "akanjs/server";
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
isImplicitRootLayoutKey,
|
|
5
|
+
type PageEntry,
|
|
6
|
+
ROOT_LAYOUT_SYSTEM,
|
|
7
|
+
resolveSsrPageEntriesForApp,
|
|
8
|
+
} from "../artifact/implicitRootLayout";
|
|
4
9
|
import { computeRouteSeedIndex, type RouteSeedIndex } from "../artifact/routeSeedIndex";
|
|
5
10
|
import type { App } from "../commandDecorators";
|
|
6
11
|
import type { ClientEntryDiscovery } from "./clientBuildTypes";
|
|
@@ -26,7 +31,7 @@ export class AllRoutesBuilder {
|
|
|
26
31
|
};
|
|
27
32
|
#knownSet = new Set<string>();
|
|
28
33
|
#routeIds: string[] = [];
|
|
29
|
-
#
|
|
34
|
+
#ssrEntriesByRoute = new Map<string, string[]>();
|
|
30
35
|
#externalsByChunkUrl: Record<string, string[]> = {};
|
|
31
36
|
#discovery: ClientEntryDiscovery | null = null;
|
|
32
37
|
|
|
@@ -41,15 +46,17 @@ export class AllRoutesBuilder {
|
|
|
41
46
|
const pageEntries = await resolveSsrPageEntriesForApp(this.#app, pageKeys);
|
|
42
47
|
const seedIndex = computeRouteSeedIndex(pageEntries);
|
|
43
48
|
this.#app.verbose(`[build-all] discovered ${seedIndex.entries.length} routes`);
|
|
44
|
-
|
|
49
|
+
const discovery = await GraphClientEntryDiscovery.create(this.#app);
|
|
50
|
+
this.#discovery = discovery;
|
|
51
|
+
const importsAs = await this.#ssrImportsAs(discovery, pageEntries);
|
|
45
52
|
|
|
46
53
|
// Every route's entries go into one Bun.build: chunk splitting only dedupes within a single build.
|
|
47
54
|
const allEntries = new Set<string>();
|
|
48
55
|
for (const entry of seedIndex.entries) {
|
|
49
56
|
const seeds = Array.from(new Set([...seedIndex.globalLayoutFiles, ...entry.seeds]));
|
|
50
|
-
const routeEntries = await
|
|
57
|
+
const routeEntries = await discovery.discover(seeds);
|
|
51
58
|
for (const discovered of routeEntries) allEntries.add(discovered);
|
|
52
|
-
this.#
|
|
59
|
+
this.#ssrEntriesByRoute.set(entry.routeId, await discovery.discover(seeds, { importsAs }));
|
|
53
60
|
this.#routeIds.push(entry.routeId);
|
|
54
61
|
}
|
|
55
62
|
this.#app.verbose(`[build-all] ${allEntries.size} client entries across ${this.#routeIds.length} routes`);
|
|
@@ -71,10 +78,21 @@ export class AllRoutesBuilder {
|
|
|
71
78
|
return { manifest, manifestPath, seedIndex };
|
|
72
79
|
}
|
|
73
80
|
|
|
81
|
+
async #ssrImportsAs(discovery: GraphClientEntryDiscovery, pageEntries: PageEntry[]) {
|
|
82
|
+
const { specifier, ssrShell, importsNoSsrPageRenders } = ROOT_LAYOUT_SYSTEM;
|
|
83
|
+
const importsAs = new Map<string, ReadonlyMap<string, string | null>>();
|
|
84
|
+
const toShell = new Map([[specifier, ssrShell]]);
|
|
85
|
+
for (const { key, moduleAbsPath } of pageEntries)
|
|
86
|
+
if (isImplicitRootLayoutKey(key)) importsAs.set(path.resolve(moduleAbsPath), toShell);
|
|
87
|
+
const system = await discovery.resolveImport(specifier, this.#app.cwdPath);
|
|
88
|
+
if (system) importsAs.set(system, new Map(importsNoSsrPageRenders.map((spec) => [spec, null])));
|
|
89
|
+
return importsAs;
|
|
90
|
+
}
|
|
91
|
+
|
|
74
92
|
async writeModulePreloads(runtimeChunks: RuntimeChunks): Promise<RouteModulePreloads> {
|
|
75
93
|
const preloads = new RouteModulePreloads({
|
|
76
94
|
runtimeChunks,
|
|
77
|
-
entriesByRoute: this.#
|
|
95
|
+
entriesByRoute: this.#ssrEntriesByRoute,
|
|
78
96
|
clientManifest: this.#merged.clientManifest,
|
|
79
97
|
externalsByChunkUrl: this.#externalsByChunkUrl,
|
|
80
98
|
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
@@ -38,8 +38,12 @@ export const CLIENT_BUNDLE_NAMING = {
|
|
|
38
38
|
asset: "assets/[hash].[ext]",
|
|
39
39
|
} as const;
|
|
40
40
|
|
|
41
|
+
export interface DiscoverOptions {
|
|
42
|
+
importsAs?: ReadonlyMap<string, ReadonlyMap<string, string | null>>;
|
|
43
|
+
}
|
|
44
|
+
|
|
41
45
|
export interface ClientEntryDiscovery {
|
|
42
|
-
discover(seeds: string[]): Promise<string[]>;
|
|
46
|
+
discover(seeds: string[], options?: DiscoverOptions): Promise<string[]>;
|
|
43
47
|
invalidate?(files: string[]): void;
|
|
44
48
|
}
|
|
45
49
|
|
|
@@ -161,4 +161,32 @@ describe("GraphClientEntryDiscovery", () => {
|
|
|
161
161
|
expect(await discovery.discover([file("A.tsx")])).toEqual([file("C.tsx")]);
|
|
162
162
|
expect(await discovery.discover([file("B.tsx")])).toEqual([file("C.tsx")]);
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
test("a file walked with its imports swapped or dropped keeps that walk's answer out of every other walk", async () => {
|
|
166
|
+
const file = await tempDir();
|
|
167
|
+
await Bun.write(file("Layout.tsx"), 'import "./Both";\n');
|
|
168
|
+
await Bun.write(file("Page.tsx"), 'import "./Layout";\n');
|
|
169
|
+
await Bun.write(file("Both.tsx"), 'import "./Server";\nimport "./Browser";\n');
|
|
170
|
+
await Bun.write(file("Server.tsx"), 'import "./S";\n');
|
|
171
|
+
await Bun.write(file("Browser.tsx"), '"use client";\nexport const Browser = () => null;\n');
|
|
172
|
+
await Bun.write(file("S.tsx"), '"use client";\nexport const S = () => null;\n');
|
|
173
|
+
const discovery = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
174
|
+
const importsAs = new Map([[file("Layout.tsx"), new Map([["./Both", "./Server"]])]]);
|
|
175
|
+
|
|
176
|
+
expect(await discovery.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
177
|
+
expect(await discovery.discover([file("Page.tsx")], { importsAs })).toEqual([file("S.tsx")]);
|
|
178
|
+
expect(await discovery.discover([file("Layout.tsx")], { importsAs })).toEqual([file("S.tsx")]);
|
|
179
|
+
expect(await discovery.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
180
|
+
const dropped = new Map([[file("Both.tsx"), new Map([["./Browser", null]])]]);
|
|
181
|
+
expect(await discovery.discover([file("Page.tsx")], { importsAs: dropped })).toEqual([file("S.tsx")]);
|
|
182
|
+
|
|
183
|
+
const fresh = new GraphClientEntryDiscovery({ barrelImports: [] }, (async () => null) as never);
|
|
184
|
+
expect(await fresh.discover([file("Page.tsx")], { importsAs })).toEqual([file("S.tsx")]);
|
|
185
|
+
expect(await fresh.discover([file("Page.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
186
|
+
expect(await fresh.discover([file("Layout.tsx")])).toEqual([file("Browser.tsx"), file("S.tsx")]);
|
|
187
|
+
|
|
188
|
+
await Bun.write(file("Server.tsx"), "export const server = 1;\n");
|
|
189
|
+
fresh.invalidate([file("Server.tsx")]);
|
|
190
|
+
expect(await fresh.discover([file("Page.tsx")], { importsAs })).toEqual([]);
|
|
191
|
+
});
|
|
164
192
|
});
|