@akanjs/devkit 3.2.0-rc.3 → 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 +80 -11
- package/akanApp/akanApp.host.test.ts +51 -0
- package/akanApp/akanApp.host.ts +3 -3
- package/akanContext.ts +2 -3
- 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.test.ts +67 -0
- package/applicationBuildRunner.ts +6 -1
- package/artifact/implicitRootLayout.ts +12 -1
- package/codeAgent/skills/akan-debug/SKILL.md +6 -2
- package/codeAgent/skills/akan-transport/SKILL.md +2 -0
- package/commandDecorators/argMeta.ts +4 -0
- package/commandDecorators/command.ts +51 -33
- package/commandDecorators/commandDecorators.test.ts +74 -9
- package/frontendBuild/allRoutesBuilder.test.ts +16 -4
- package/frontendBuild/allRoutesBuilder.ts +24 -6
- package/frontendBuild/bundleDefine.ts +2 -0
- package/frontendBuild/clientBuildTypes.ts +5 -2
- package/frontendBuild/clientEntryDiscovery.test.ts +28 -0
- package/frontendBuild/clientEntryDiscovery.ts +25 -6
- package/frontendBuild/nativeCore.test.ts +148 -0
- package/frontendBuild/ssrBaseArtifactBuilder.test.ts +26 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +23 -19
- package/frontendBuild/vendorImportScanner.test.ts +24 -3
- package/frontendBuild/vendorImportScanner.ts +25 -10
- package/incrementalBuilder/incrementalBuilder.host.test.ts +5 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +3 -3
- package/{constantFieldNames.test.ts → moduleFieldNames.test.ts} +24 -2
- package/moduleFieldNames.ts +25 -0
- package/package.json +3 -3
- package/preview/PreviewView.ts +32 -13
- package/preview/previewView.test.ts +33 -3
- package/routeJsBudget.test.ts +47 -11
- package/routeJsBudget.ts +88 -25
- package/workflow/plainName.ts +16 -3
- package/constantFieldNames.ts +0 -18
|
@@ -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
|
|
|
@@ -7,6 +7,7 @@ import { ProductionLockfile } from "./akanConfig/productionLockfile";
|
|
|
7
7
|
import { AKAN_BACKEND_MINIFY, AKAN_OPTIONAL_BACKEND_EXTERNALS, ApplicationBuildRunner } from "./applicationBuildRunner";
|
|
8
8
|
import type { App } from "./commandDecorators";
|
|
9
9
|
import { AppExecutor, Executor } from "./executors";
|
|
10
|
+
import { AllRoutesBuilder, type BuildAllRoutesResult, VendorNarrowing } from "./frontendBuild";
|
|
10
11
|
import { bundleDefine } from "./frontendBuild/bundleDefine";
|
|
11
12
|
import { createTempApp } from "./testHelpers";
|
|
12
13
|
import { ProjectTypecheck } from "./typecheck/projectTypecheck";
|
|
@@ -307,6 +308,72 @@ console.info(new SampleService().name);
|
|
|
307
308
|
}
|
|
308
309
|
});
|
|
309
310
|
|
|
311
|
+
const buildSsrUntilVendors = async (routeChunkWritten: boolean) => {
|
|
312
|
+
const { root, workspace } = await createTempApp("portal");
|
|
313
|
+
const keys = ["AKAN_PUBLIC_ENV", "AKAN_PUBLIC_REPO_NAME", "AKAN_PUBLIC_SERVE_DOMAIN"] as const;
|
|
314
|
+
const shellEnv = Object.fromEntries(keys.map((key) => [key, process.env[key]]));
|
|
315
|
+
await Promise.all(["main.ts", "server.ts"].map((file) => writeFile(path.join(root, "apps", "portal", file), "")));
|
|
316
|
+
const app = AppExecutor.from(workspace, "portal");
|
|
317
|
+
app.prepareCommand = async () => ({ env: {} });
|
|
318
|
+
app.getPageKeys = async () => ["_index.tsx"];
|
|
319
|
+
const order: string[] = [];
|
|
320
|
+
let narrowed: Record<string, readonly string[]> = {};
|
|
321
|
+
const entryExports = VendorNarrowing.prototype.entryExports;
|
|
322
|
+
const bundle = spyOn(Bun, "build").mockImplementation(
|
|
323
|
+
async () => ({ success: true, outputs: [], logs: [] }) as unknown as Bun.BuildOutput,
|
|
324
|
+
);
|
|
325
|
+
const routes = spyOn(AllRoutesBuilder.prototype, "build").mockImplementation(async () => {
|
|
326
|
+
order.push("routes");
|
|
327
|
+
if (routeChunkWritten)
|
|
328
|
+
await Bun.write(
|
|
329
|
+
path.join(app.dist.cwdPath, ".akan/artifact/client/page-a1.js"),
|
|
330
|
+
'import{st}from"akanjs/store";',
|
|
331
|
+
);
|
|
332
|
+
const clientManifest = { "page#default": { id: "/_akan/client/page-a1.js", chunks: [], name: "default" } };
|
|
333
|
+
return { manifest: { routeIds: ["/"], clientManifest } } as unknown as BuildAllRoutesResult;
|
|
334
|
+
});
|
|
335
|
+
const vendors = spyOn(VendorNarrowing.prototype, "entryExports").mockImplementation(function (
|
|
336
|
+
this: VendorNarrowing,
|
|
337
|
+
entries,
|
|
338
|
+
) {
|
|
339
|
+
order.push("vendors");
|
|
340
|
+
narrowed = entryExports.call(this, entries);
|
|
341
|
+
throw new Error("stopped at the vendor build");
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
const failure = await new ApplicationBuildRunner(app, { fast: true }).build().then(
|
|
345
|
+
() => "built",
|
|
346
|
+
(error: unknown) => (error instanceof Error ? error.message : String(error)),
|
|
347
|
+
);
|
|
348
|
+
return { failure, order, narrowed };
|
|
349
|
+
} finally {
|
|
350
|
+
bundle.mockRestore();
|
|
351
|
+
routes.mockRestore();
|
|
352
|
+
vendors.mockRestore();
|
|
353
|
+
for (const key of keys) {
|
|
354
|
+
if (shellEnv[key] === undefined) delete process.env[key];
|
|
355
|
+
else process.env[key] = shellEnv[key];
|
|
356
|
+
}
|
|
357
|
+
await rm(root, { recursive: true, force: true });
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
|
|
361
|
+
test("bundles the routes before the vendor entries, which export only the names the route chunks import", async () => {
|
|
362
|
+
const { failure, order, narrowed } = await buildSsrUntilVendors(true);
|
|
363
|
+
|
|
364
|
+
expect(failure).toBe("stopped at the vendor build");
|
|
365
|
+
expect(order).toEqual(["routes", "vendors"]);
|
|
366
|
+
expect(Object.entries(narrowed).find(([entry]) => entry.endsWith("akanjs-store.ts"))?.[1]).toEqual(["st"]);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("stops before the vendor entries when a route's client entry is not where the scan reads", async () => {
|
|
370
|
+
const { failure, order } = await buildSsrUntilVendors(false);
|
|
371
|
+
|
|
372
|
+
expect(failure).toContain("no file under");
|
|
373
|
+
expect(failure).toContain("/_akan/client/page-a1.js");
|
|
374
|
+
expect(order).toEqual(["routes"]);
|
|
375
|
+
});
|
|
376
|
+
|
|
310
377
|
test("akan typecheck names the script/ files nothing imports, and a build's typecheck leaves them out", async () => {
|
|
311
378
|
const { root, app } = await createTempApp("portal");
|
|
312
379
|
const run = spyOn(ProjectTypecheck.prototype, "run").mockImplementation(async () => "");
|
|
@@ -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 {
|
|
@@ -437,6 +440,7 @@ void run().catch((error) => {
|
|
|
437
440
|
const vendorExports = await VendorImportScanner.exportsUsedIn(
|
|
438
441
|
path.join(this.#app.dist.cwdPath, ".akan/artifact/client"),
|
|
439
442
|
NARROWABLE_VENDOR_SPECIFIERS,
|
|
443
|
+
Object.values(allRoutes.manifest.clientManifest).map(({ id }) => id),
|
|
440
444
|
);
|
|
441
445
|
const base = await new SsrBaseArtifactBuilder(this.#app, "build", {
|
|
442
446
|
serverEnv: this.#serverEnvPlugin(baseDevEnv.env, branches),
|
|
@@ -456,6 +460,7 @@ void run().catch((error) => {
|
|
|
456
460
|
const budgetPath = path.join(this.#app.cwdPath, RouteJsBudget.fileName);
|
|
457
461
|
const { summary: status, warnings } = await RouteJsBudget.check(sizes, {
|
|
458
462
|
mode: this.#budget,
|
|
463
|
+
app: this.#app.name,
|
|
459
464
|
budgetPath,
|
|
460
465
|
source: path.relative(this.#app.workspace.workspaceRoot, budgetPath),
|
|
461
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)};
|
|
@@ -15,8 +15,12 @@ the server errors logged meanwhile, console errors and warnings, and layout smel
|
|
|
15
15
|
stops it after; `--keep` leaves it up for the next preview. It never starts a second server beside a
|
|
16
16
|
running one.
|
|
17
17
|
- Paths are comma-separated (`akan preview <app> /board,/board/new`) and get the default locale when they lack
|
|
18
|
-
one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off.
|
|
19
|
-
(`/_akan/studio?view=runtime`), a
|
|
18
|
+
one, unless the app sets `localePrefix: "as-needed"` and `--csr` is off. What the server answers outside a
|
|
19
|
+
locale opens as written: a `/_akan` route such as Studio (`/_akan/studio?view=runtime`), a route under the
|
|
20
|
+
API prefix, `/__csr`, `/__rsc`, `/.well-known`, `/openapi.json`, the default `/mcp`, `/favicon.ico`,
|
|
21
|
+
`/robots.txt`, `/sitemap.xml`, `manifest.webmanifest` and a file in the app's `public/`. A full URL
|
|
22
|
+
(`http://localhost:<port>/oauth/token`) opens exactly as written: use it for an endpoint mounted outside the
|
|
23
|
+
API prefix or a custom MCP path. A page `--changed` finds always gets the locale.
|
|
20
24
|
`--changed` adds the pages your uncommitted page and layout edits reach. A dynamic route needs a concrete
|
|
21
25
|
path (`/memo/1`); `akan context --app <app>` lists every route with its params and search names.
|
|
22
26
|
- The viewport is a 390x844 phone; `--desktop` is 1280x800. `--csr` loads each page in the CSR boot the native
|
|
@@ -27,6 +27,8 @@ description: "Guard and tune endpoints on the wire: writing a Can<Verb><Model> g
|
|
|
27
27
|
- `{ rateLimit: { calls, windowMs?, by? } }` per endpoint, `option.setRateLimit` per app, `plug(RateLimit)` for a
|
|
28
28
|
business key.
|
|
29
29
|
- A mutation repeated under one `Idempotency-Key` header runs once and replays its first answer.
|
|
30
|
+
- A browser send that changes nothing read back (typing status, a cursor move) passes `{ keepsReads: true }`, so it
|
|
31
|
+
neither ends in-flight query sharing nor drops prefetched pages.
|
|
30
32
|
- `{ method: "PATCH" | "PUT" | "DELETE" }` moves a mutation off `POST` — only when a foreign protocol forces it.
|
|
31
33
|
- `errors: { 409: ["ticket.error.locked"] }` names the errors an endpoint throws on purpose.
|
|
32
34
|
|
|
@@ -53,6 +53,7 @@ export interface ArgMeta<Context = CommandContext> {
|
|
|
53
53
|
export interface InternalArgOption {
|
|
54
54
|
nullable?: boolean;
|
|
55
55
|
onlyApp?: boolean;
|
|
56
|
+
services?: boolean;
|
|
56
57
|
}
|
|
57
58
|
export interface InternalArgMeta {
|
|
58
59
|
key: string;
|
|
@@ -112,5 +113,8 @@ export type Pkg = PkgExecutor;
|
|
|
112
113
|
export const Module = createInternalArgToken<ModuleExecutor, "Module">("Module");
|
|
113
114
|
export type Module = ModuleExecutor;
|
|
114
115
|
|
|
116
|
+
/** Module, or a service module (`lib/_<name>`) named as `<sys>:_<name>`. */
|
|
117
|
+
export const ModuleOrService = createInternalArgToken<ModuleExecutor, "Module">("Module", { services: true });
|
|
118
|
+
|
|
115
119
|
export const Workspace = createInternalArgToken<WorkspaceExecutor, "Workspace">("Workspace");
|
|
116
120
|
export type Workspace = WorkspaceExecutor;
|