@akanjs/devkit 2.4.0 → 2.4.1-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/akanApp/akanApp.host.ts +19 -2
- package/cloud/constants.ts +1 -1
- package/commandDecorators/command.ts +8 -16
- package/commandDecorators/targetMeta.ts +18 -0
- package/executors.ts +20 -149
- package/incrementalBuilder/incrementalBuilder.host.ts +1 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +57 -19
- package/index.ts +54 -41
- package/integration/devStability.integration.test.ts +98 -0
- package/integration/devStabilityHarness.ts +84 -3
- package/package.json +15 -3
- package/routeSourceValidator.ts +158 -0
- package/scanInfo.ts +7 -3
- package/src/capacitorApp.ts +2 -1
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { createTunnel } from "../createTunnel";
|
|
|
6
6
|
import { WorkspaceExecutor } from "../executors";
|
|
7
7
|
import { IncrementalBuilderHost } from "../incrementalBuilder";
|
|
8
8
|
|
|
9
|
-
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route"]);
|
|
9
|
+
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
10
10
|
const BACKEND_RESTART_DEBOUNCE_MS = 120;
|
|
11
11
|
// Must exceed the gateway's child-wait budget (AkanApp child shutdown, ~5s in dev) so the gateway
|
|
12
12
|
// is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
|
|
@@ -955,13 +955,30 @@ export class AkanAppHost {
|
|
|
955
955
|
});
|
|
956
956
|
}
|
|
957
957
|
#sendToBuilder(message: BuilderMessage): void {
|
|
958
|
+
// The builder skips dev CSR artifacts until a `?csr=true` request needs one. Remember that this
|
|
959
|
+
// session armed it and pass the flag through `env`, which is re-read on every builder spawn, so a
|
|
960
|
+
// builder restart re-arms itself instead of silently breaking an in-progress mobile dev session.
|
|
961
|
+
if (message.type === "build-csr" && this.env.AKAN_DEV_CSR_REBUILD !== "1") {
|
|
962
|
+
Object.assign(this.env, { AKAN_DEV_CSR_REBUILD: "1" });
|
|
963
|
+
this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
|
|
964
|
+
}
|
|
958
965
|
if (this.#builder?.send(message)) return;
|
|
966
|
+
const status = this.#builder?.status ?? "stopped";
|
|
959
967
|
if (message.type === "build-route") {
|
|
960
968
|
this.#sendToBackend({
|
|
961
969
|
type: "build-route-res",
|
|
962
970
|
id: message.id,
|
|
963
971
|
ok: false,
|
|
964
|
-
error: `builder is ${
|
|
972
|
+
error: `builder is ${status}; reload after the builder is ready`,
|
|
973
|
+
});
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
if (message.type === "build-csr") {
|
|
977
|
+
this.#sendToBackend({
|
|
978
|
+
type: "build-csr-res",
|
|
979
|
+
id: message.id,
|
|
980
|
+
ok: false,
|
|
981
|
+
error: `builder is ${status}; reload after the builder is ready`,
|
|
965
982
|
});
|
|
966
983
|
return;
|
|
967
984
|
}
|
package/cloud/constants.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { Dayjs } from "dayjs";
|
|
2
|
-
import { GlobalConfig } from "..";
|
|
3
2
|
import type { SupportedLlmModel } from "../aiEditor";
|
|
3
|
+
import { GlobalConfig } from "./globalConfig";
|
|
4
4
|
|
|
5
5
|
export const basePath = `${Bun.env.HOME ?? Bun.env.USERPROFILE}/.akan`;
|
|
6
6
|
export const configPath = `${basePath}/config.json`;
|
|
@@ -3,9 +3,13 @@ import { confirm, input, select } from "@inquirer/prompts";
|
|
|
3
3
|
import { Logger } from "akanjs/common";
|
|
4
4
|
import chalk from "chalk";
|
|
5
5
|
import { type Command, program } from "commander";
|
|
6
|
-
|
|
7
|
-
import { FileSys, getDirname, type PackageJson } from "..";
|
|
8
6
|
import { AppExecutor, Executor, LibExecutor, ModuleExecutor, PkgExecutor, WorkspaceExecutor } from "../executors";
|
|
7
|
+
// Import the owning modules directly, never the root barrel: `..` re-exports all 41 devkit modules,
|
|
8
|
+
// so a barrel import here drags ink, @trapezedev/project, ssh2, @langchain/* and the cloud stack into
|
|
9
|
+
// every process that registers a command (measured: 236MB vs 3MB).
|
|
10
|
+
import { FileSys } from "../fileSys";
|
|
11
|
+
import { getDirname } from "../getDirname";
|
|
12
|
+
import type { PackageJson } from "../types";
|
|
9
13
|
import {
|
|
10
14
|
type ArgMeta,
|
|
11
15
|
type CommandContext,
|
|
@@ -16,7 +20,7 @@ import {
|
|
|
16
20
|
} from "./argMeta";
|
|
17
21
|
import { CommandContainer } from "./dependencyBuilder";
|
|
18
22
|
import { formatCommandHelp, formatHelp } from "./helpFormatter";
|
|
19
|
-
import { type CommandCls, getTargetMetas } from "./targetMeta";
|
|
23
|
+
import { type CommandCls, getTargetCommandNames, getTargetMetas } from "./targetMeta";
|
|
20
24
|
|
|
21
25
|
const camelToKebabCase = (str: string) => str.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
22
26
|
const loggedCliErrorObjects = new WeakSet<object>();
|
|
@@ -295,19 +299,7 @@ It may cause unexpected behavior. Run \`akan update\` to update latest akanjs.`,
|
|
|
295
299
|
for (const command of commands) {
|
|
296
300
|
const targetMetas = getTargetMetas(command);
|
|
297
301
|
for (const targetMeta of targetMetas) {
|
|
298
|
-
const
|
|
299
|
-
const commandNames =
|
|
300
|
-
targetMeta.targetOption.short === true
|
|
301
|
-
? [
|
|
302
|
-
kebabKey,
|
|
303
|
-
typeof targetMeta.targetOption.short === "string"
|
|
304
|
-
? targetMeta.targetOption.short
|
|
305
|
-
: kebabKey
|
|
306
|
-
.split("-")
|
|
307
|
-
.map((s) => s.slice(0, 1))
|
|
308
|
-
.join(""),
|
|
309
|
-
]
|
|
310
|
-
: [kebabKey];
|
|
302
|
+
const commandNames = getTargetCommandNames(targetMeta);
|
|
311
303
|
for (const commandName of commandNames) {
|
|
312
304
|
let programCommand = program.command(commandName, {
|
|
313
305
|
hidden: targetMeta.targetOption.devOnly,
|
|
@@ -22,6 +22,24 @@ export const getTargetMetas = (command: CommandCls): TargetMeta[] => {
|
|
|
22
22
|
return [...targetMetaMap.values()];
|
|
23
23
|
};
|
|
24
24
|
|
|
25
|
+
const camelToKebabCase = (str: string) => str.replace(/([A-Z])/g, "-$1").toLowerCase();
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* CLI names a target answers to. Shared with the command-manifest generator so a lazily-loaded CLI
|
|
29
|
+
* resolves `argv[2]` to the same module that `runCommands` would have registered it under.
|
|
30
|
+
*/
|
|
31
|
+
export const getTargetCommandNames = (targetMeta: TargetMeta): string[] => {
|
|
32
|
+
const kebabKey = camelToKebabCase(targetMeta.key);
|
|
33
|
+
if (targetMeta.targetOption.short !== true) return [kebabKey];
|
|
34
|
+
return [
|
|
35
|
+
kebabKey,
|
|
36
|
+
kebabKey
|
|
37
|
+
.split("-")
|
|
38
|
+
.map((s) => s.slice(0, 1))
|
|
39
|
+
.join(""),
|
|
40
|
+
];
|
|
41
|
+
};
|
|
42
|
+
|
|
25
43
|
export interface TargetOption {
|
|
26
44
|
type: "public" | "cloud" | "dev";
|
|
27
45
|
short?: string | true;
|
package/executors.ts
CHANGED
|
@@ -22,14 +22,15 @@ import {
|
|
|
22
22
|
} from "akanjs/common";
|
|
23
23
|
import { $ } from "bun";
|
|
24
24
|
import chalk from "chalk";
|
|
25
|
-
import ts from "typescript";
|
|
26
25
|
import { AkanAppConfig, AkanLibConfig, decreaseBuildNum, increaseBuildNum } from "./akanConfig";
|
|
27
26
|
import { FileSys } from "./fileSys";
|
|
28
27
|
import { getDirname } from "./getDirname";
|
|
29
28
|
import { Linter } from "./linter";
|
|
30
29
|
import { AppInfo, LibInfo, PkgInfo, WorkspaceInfo } from "./scanInfo";
|
|
31
30
|
import { Spinner } from "./spinner";
|
|
32
|
-
|
|
31
|
+
// Type-only: the implementation is loaded on demand in `getTypeChecker` to keep `typescript` out of
|
|
32
|
+
// the resident module graph.
|
|
33
|
+
import type { TypeChecker } from "./typeChecker";
|
|
33
34
|
import type { FileContent, PackageJson, TsConfigJson } from "./types";
|
|
34
35
|
|
|
35
36
|
const staticTemplateFileExtensions = new Set([
|
|
@@ -153,148 +154,6 @@ const parseEnvFile = (envPath: string): Record<string, string> => {
|
|
|
153
154
|
return env;
|
|
154
155
|
};
|
|
155
156
|
|
|
156
|
-
const PAGE_ROUTE_EXPORTS = new Set([
|
|
157
|
-
"default",
|
|
158
|
-
"pageConfig",
|
|
159
|
-
"head",
|
|
160
|
-
"metadata",
|
|
161
|
-
"generateHead",
|
|
162
|
-
"generateMetadata",
|
|
163
|
-
"Loading",
|
|
164
|
-
]);
|
|
165
|
-
const ROOT_LAYOUT_EXPORTS = new Set([
|
|
166
|
-
"default",
|
|
167
|
-
"pageConfig",
|
|
168
|
-
"head",
|
|
169
|
-
"metadata",
|
|
170
|
-
"generateHead",
|
|
171
|
-
"generateMetadata",
|
|
172
|
-
"fonts",
|
|
173
|
-
"manifest",
|
|
174
|
-
"theme",
|
|
175
|
-
"reconnect",
|
|
176
|
-
"layoutStyle",
|
|
177
|
-
"gaTrackingId",
|
|
178
|
-
"Loading",
|
|
179
|
-
"NotFound",
|
|
180
|
-
"Error",
|
|
181
|
-
]);
|
|
182
|
-
const LAYOUT_ROUTE_EXPORTS = new Set([
|
|
183
|
-
"default",
|
|
184
|
-
"pageConfig",
|
|
185
|
-
"head",
|
|
186
|
-
"metadata",
|
|
187
|
-
"generateHead",
|
|
188
|
-
"generateMetadata",
|
|
189
|
-
"Loading",
|
|
190
|
-
"NotFound",
|
|
191
|
-
"Error",
|
|
192
|
-
]);
|
|
193
|
-
|
|
194
|
-
function validateRouteSourceExports(
|
|
195
|
-
source: string,
|
|
196
|
-
filePath: string,
|
|
197
|
-
kind: "page" | "layout",
|
|
198
|
-
options: { rootLayout?: boolean } = {},
|
|
199
|
-
) {
|
|
200
|
-
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
201
|
-
const allowed =
|
|
202
|
-
kind === "page" ? PAGE_ROUTE_EXPORTS : options.rootLayout ? ROOT_LAYOUT_EXPORTS : LAYOUT_ROUTE_EXPORTS;
|
|
203
|
-
const exported = new Set<string>();
|
|
204
|
-
const assertExport = (name: string) => {
|
|
205
|
-
if (!allowed.has(name)) {
|
|
206
|
-
throw new Error(`[route-convention] unsupported export "${name}" in ${filePath}`);
|
|
207
|
-
}
|
|
208
|
-
exported.add(name);
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
for (const statement of sourceFile.statements) {
|
|
212
|
-
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) continue;
|
|
213
|
-
if (ts.isExportDeclaration(statement)) {
|
|
214
|
-
if (statement.isTypeOnly) continue;
|
|
215
|
-
const clause = statement.exportClause;
|
|
216
|
-
if (!clause) throw new Error(`[route-convention] export * is not allowed in route modules: ${filePath}`);
|
|
217
|
-
if (ts.isNamedExports(clause)) {
|
|
218
|
-
for (const element of clause.elements) {
|
|
219
|
-
if (element.isTypeOnly) continue;
|
|
220
|
-
assertExport(element.name.text);
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
continue;
|
|
224
|
-
}
|
|
225
|
-
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
|
226
|
-
const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
227
|
-
if (!isExported) continue;
|
|
228
|
-
const isDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
|
|
229
|
-
if (isDefault) {
|
|
230
|
-
assertExport("default");
|
|
231
|
-
continue;
|
|
232
|
-
}
|
|
233
|
-
if (ts.isVariableStatement(statement)) {
|
|
234
|
-
for (const declaration of statement.declarationList.declarations) {
|
|
235
|
-
if (ts.isIdentifier(declaration.name)) assertExport(declaration.name.text);
|
|
236
|
-
}
|
|
237
|
-
continue;
|
|
238
|
-
}
|
|
239
|
-
const name = (statement as unknown as { name?: ts.Node }).name;
|
|
240
|
-
if (name && ts.isIdentifier(name)) {
|
|
241
|
-
assertExport(name.text);
|
|
242
|
-
}
|
|
243
|
-
}
|
|
244
|
-
if (exported.has("head") && exported.has("generateHead")) {
|
|
245
|
-
throw new Error(`[route-convention] head and generateHead cannot both be exported in ${filePath}`);
|
|
246
|
-
}
|
|
247
|
-
if (
|
|
248
|
-
!options.rootLayout &&
|
|
249
|
-
(exported.has("head") || exported.has("generateHead")) &&
|
|
250
|
-
(exported.has("metadata") || exported.has("generateMetadata"))
|
|
251
|
-
) {
|
|
252
|
-
throw new Error(
|
|
253
|
-
`[route-convention] head/generateHead and metadata/generateMetadata cannot both be exported in ${filePath}`,
|
|
254
|
-
);
|
|
255
|
-
}
|
|
256
|
-
if (exported.has("metadata") && exported.has("generateMetadata")) {
|
|
257
|
-
throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
|
|
258
|
-
}
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
/**
|
|
262
|
-
* Statically enforces that a `_overrides.tsx` route file is a logic-free activation manifest: a plain module
|
|
263
|
-
* (no `"use client"` — the framework generates the client wrapper) that only imports components and binds them
|
|
264
|
-
* to slots through a single `export default override({ Modal: BrandModal })`. It must not declare components
|
|
265
|
-
* inline or run logic — that keeps the override contract a thin binding layer rather than a second place to
|
|
266
|
-
* author UI. Slot names and value types are validated at compile time by `override`.
|
|
267
|
-
*/
|
|
268
|
-
function validateOverridesSourceExports(source: string, filePath: string) {
|
|
269
|
-
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
270
|
-
const fail = (message: string): never => {
|
|
271
|
-
throw new Error(`[route-convention] ${message}: ${filePath}`);
|
|
272
|
-
};
|
|
273
|
-
let defaultOverride: ts.ExportAssignment | null = null;
|
|
274
|
-
for (const statement of sourceFile.statements) {
|
|
275
|
-
// A "use client" directive is unnecessary (the framework wraps the manifest) but harmless if present.
|
|
276
|
-
if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) continue;
|
|
277
|
-
// The manifest imports the app components it binds; imports and type-only decls carry no runtime logic.
|
|
278
|
-
if (ts.isImportDeclaration(statement)) continue;
|
|
279
|
-
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) continue;
|
|
280
|
-
if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
281
|
-
defaultOverride = statement;
|
|
282
|
-
continue;
|
|
283
|
-
}
|
|
284
|
-
fail(`_overrides.tsx may only contain imports and a single "export default override({ ... })"`);
|
|
285
|
-
}
|
|
286
|
-
if (!defaultOverride) fail(`_overrides.tsx must "export default override({ ... })"`);
|
|
287
|
-
const expression = defaultOverride.expression;
|
|
288
|
-
if (
|
|
289
|
-
!ts.isCallExpression(expression) ||
|
|
290
|
-
!ts.isIdentifier(expression.expression) ||
|
|
291
|
-
expression.expression.text !== "override"
|
|
292
|
-
)
|
|
293
|
-
fail(
|
|
294
|
-
`_overrides.tsx default export must be a call to "override", e.g. "export default override({ Modal: BrandModal })"`,
|
|
295
|
-
);
|
|
296
|
-
}
|
|
297
|
-
|
|
298
157
|
export class Executor {
|
|
299
158
|
static verbose = false;
|
|
300
159
|
static setVerbose(verbose: boolean) {
|
|
@@ -798,13 +657,17 @@ export class Executor {
|
|
|
798
657
|
};
|
|
799
658
|
return this._applyTemplate({ ...options, dict });
|
|
800
659
|
}
|
|
801
|
-
|
|
660
|
+
// Async so `typescript` (~65MB resident) is loaded only by the commands that actually typecheck,
|
|
661
|
+
// not by every process that imports an executor. `typeCheckAsync` below runs in a subprocess and
|
|
662
|
+
// never touches this path.
|
|
663
|
+
async getTypeChecker() {
|
|
664
|
+
const { TypeChecker } = await import("./typeChecker");
|
|
802
665
|
this.typeChecker ??= new TypeChecker(this);
|
|
803
666
|
return this.typeChecker;
|
|
804
667
|
}
|
|
805
|
-
typeCheck(filePath: string) {
|
|
668
|
+
async typeCheck(filePath: string) {
|
|
806
669
|
const path = this.getPath(filePath);
|
|
807
|
-
const typeChecker = this.getTypeChecker();
|
|
670
|
+
const typeChecker = await this.getTypeChecker();
|
|
808
671
|
const { fileDiagnostics, fileErrors, fileWarnings } = typeChecker.check(path);
|
|
809
672
|
const message = typeChecker.formatDiagnostics(fileDiagnostics);
|
|
810
673
|
return { fileDiagnostics, fileErrors, fileWarnings, message };
|
|
@@ -1370,6 +1233,13 @@ interface AppExecutorOptions {
|
|
|
1370
1233
|
name: string;
|
|
1371
1234
|
}
|
|
1372
1235
|
export class AppExecutor extends SysExecutor {
|
|
1236
|
+
// `typescript` costs ~65MB resident, so keep it out of the module graph of every process that only
|
|
1237
|
+
// ever imports an executor. Route validation is the sole consumer here and is already async.
|
|
1238
|
+
static #routeSourceValidator: typeof import("./routeSourceValidator").RouteSourceValidator | null = null;
|
|
1239
|
+
static async #getRouteSourceValidator() {
|
|
1240
|
+
AppExecutor.#routeSourceValidator ??= (await import("./routeSourceValidator")).RouteSourceValidator;
|
|
1241
|
+
return AppExecutor.#routeSourceValidator;
|
|
1242
|
+
}
|
|
1373
1243
|
dist: Executor;
|
|
1374
1244
|
override emoji = execEmoji.app;
|
|
1375
1245
|
constructor({ workspace, name }: AppExecutorOptions) {
|
|
@@ -1502,8 +1372,9 @@ export class AppExecutor extends SysExecutor {
|
|
|
1502
1372
|
}
|
|
1503
1373
|
const isRootLayout = parsed.kind === "layout" && parsed.moduleSegments.at(-1) === "_layout";
|
|
1504
1374
|
const routeSource = await Bun.file(absPath).text();
|
|
1505
|
-
|
|
1506
|
-
|
|
1375
|
+
const validator = await AppExecutor.#getRouteSourceValidator();
|
|
1376
|
+
if (parsed.kind === "overrides") validator.validateOverridesSourceExports(routeSource, absPath);
|
|
1377
|
+
else validator.validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
|
|
1507
1378
|
pageKeys.push(key);
|
|
1508
1379
|
}
|
|
1509
1380
|
pageKeys.sort();
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
+
// Subpath imports only: the `@akanjs/devkit` root barrel re-exports all 41 modules, which would drag
|
|
3
|
+
// @trapezedev/project, the @langchain stack, ssh2, ink and the cloud stack into the builder process.
|
|
4
|
+
import type { App } from "@akanjs/devkit/commandDecorators";
|
|
5
|
+
import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
|
|
2
6
|
import {
|
|
3
|
-
type App,
|
|
4
|
-
AppExecutor,
|
|
5
7
|
AutoImportSync,
|
|
6
8
|
type ChangeBatch,
|
|
7
9
|
type ClientEntryDiscovery,
|
|
@@ -16,11 +18,12 @@ import {
|
|
|
16
18
|
RouteClientBuilder,
|
|
17
19
|
SsrBaseArtifactBuilder,
|
|
18
20
|
WatchRootResolver,
|
|
19
|
-
|
|
20
|
-
} from "@akanjs/devkit";
|
|
21
|
+
} from "@akanjs/devkit/frontendBuild";
|
|
21
22
|
import { Logger } from "akanjs/common";
|
|
22
23
|
import type {
|
|
23
24
|
BaseBuildArtifact,
|
|
25
|
+
BuilderCsrReq,
|
|
26
|
+
BuilderCsrRes,
|
|
24
27
|
BuilderMessage,
|
|
25
28
|
BuilderReq,
|
|
26
29
|
BuilderRes,
|
|
@@ -56,6 +59,7 @@ class IncrementalBuilder {
|
|
|
56
59
|
#generatedIndexSync: DevGeneratedIndexSync;
|
|
57
60
|
#autoImportSync: AutoImportSync;
|
|
58
61
|
#generation = 0;
|
|
62
|
+
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
59
63
|
#workQueue: Promise<void> = Promise.resolve();
|
|
60
64
|
#cssRebuildQueue: Promise<void> = Promise.resolve();
|
|
61
65
|
#cssRebuildTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -346,7 +350,9 @@ class IncrementalBuilder {
|
|
|
346
350
|
this.#sendBuildStatus("csr", { generation, ok: false, files, message });
|
|
347
351
|
}
|
|
348
352
|
} else if (kinds.includes("code") && rebuildClient) {
|
|
349
|
-
this.#logger.verbose(
|
|
353
|
+
this.#logger.verbose(
|
|
354
|
+
`csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
|
|
355
|
+
);
|
|
350
356
|
}
|
|
351
357
|
|
|
352
358
|
process.send?.(event);
|
|
@@ -404,14 +410,40 @@ class IncrementalBuilder {
|
|
|
404
410
|
});
|
|
405
411
|
}
|
|
406
412
|
|
|
413
|
+
/**
|
|
414
|
+
* Build the dev CSR artifact because a request asked for it, and keep it in sync from now on. The
|
|
415
|
+
* dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
|
|
416
|
+
* points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
|
|
417
|
+
*/
|
|
418
|
+
async handleBuildCsr(msg: BuilderCsrReq): Promise<BuilderCsrRes> {
|
|
419
|
+
return this.#enqueueWork("build-csr", async (): Promise<BuilderCsrRes> => {
|
|
420
|
+
const started = Date.now();
|
|
421
|
+
try {
|
|
422
|
+
await new CsrArtifactBuilder(this.#app).build();
|
|
423
|
+
this.#csrActive = true;
|
|
424
|
+
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
425
|
+
return { type: "build-csr-res", id: msg.id, ok: true };
|
|
426
|
+
} catch (err) {
|
|
427
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
428
|
+
this.#logger.error(`csr-build failed: ${message}`);
|
|
429
|
+
return { type: "build-csr-res", id: msg.id, ok: false, error: message };
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
|
|
407
434
|
#shouldRebuildCsr() {
|
|
408
|
-
|
|
409
|
-
|
|
435
|
+
return this.#csrActive;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
static #csrArmedByEnv() {
|
|
439
|
+
return process.env.AKAN_DEV_CSR_REBUILD === "1";
|
|
410
440
|
}
|
|
411
441
|
|
|
412
442
|
static async #buildBootDeps(app: App): Promise<IncrementalBuilderBootDeps> {
|
|
413
443
|
const { artifact, cssCompiler, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
|
|
414
|
-
|
|
444
|
+
//* A full minified browser-target build of every page costs ~350MB of bundler arena the process
|
|
445
|
+
//* never returns, so skip it until a `/__csr` or `?csr=true` request arms it via `build-csr`.
|
|
446
|
+
if (IncrementalBuilder.#csrArmedByEnv()) await new CsrArtifactBuilder(app).build();
|
|
415
447
|
const discovery = await GraphClientEntryDiscovery.create(app);
|
|
416
448
|
return { artifact, cssCompiler, optimizedFonts, discovery };
|
|
417
449
|
}
|
|
@@ -476,20 +508,26 @@ class IncrementalBuilder {
|
|
|
476
508
|
const app = AppExecutor.from(workspace, appName);
|
|
477
509
|
const watch = process.env.AKAN_WATCH !== "0";
|
|
478
510
|
let builder: IncrementalBuilder | null = null;
|
|
479
|
-
// Registered before the boot build so
|
|
480
|
-
//
|
|
511
|
+
// Registered before the boot build so backend requests get an error response (instead of hanging
|
|
512
|
+
// the backend) while the builder is still booting or recovering from a failed build.
|
|
513
|
+
const bootingError = "builder is recovering from a failed boot build; retry after the build error is fixed";
|
|
481
514
|
process.on("message", (msg: BuilderMessage) => {
|
|
482
|
-
if (!msg || typeof msg !== "object"
|
|
483
|
-
if (
|
|
484
|
-
|
|
485
|
-
type: "build-route-res",
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
});
|
|
515
|
+
if (!msg || typeof msg !== "object") return;
|
|
516
|
+
if (msg.type === "build-route") {
|
|
517
|
+
if (!builder) {
|
|
518
|
+
process.send?.({ type: "build-route-res", id: msg.id, ok: false, error: bootingError });
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
|
|
490
522
|
return;
|
|
491
523
|
}
|
|
492
|
-
|
|
524
|
+
if (msg.type === "build-csr") {
|
|
525
|
+
if (!builder) {
|
|
526
|
+
process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error: bootingError });
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
void builder.handleBuildCsr(msg).then((res) => process.send?.(res));
|
|
530
|
+
}
|
|
493
531
|
});
|
|
494
532
|
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
495
533
|
// as an orphaned watcher that keeps rebuilding for nobody.
|
package/index.ts
CHANGED
|
@@ -1,41 +1,54 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
export
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
export * from "./
|
|
16
|
-
export * from "./
|
|
17
|
-
export * from "./
|
|
18
|
-
export * from "./
|
|
19
|
-
export * from "./
|
|
20
|
-
export * from "./
|
|
21
|
-
export * from "./
|
|
22
|
-
export * from "./
|
|
23
|
-
export * from "./
|
|
24
|
-
export * from "./
|
|
25
|
-
export * from "./
|
|
26
|
-
export * from "./
|
|
27
|
-
export * from "./
|
|
28
|
-
export * from "./
|
|
29
|
-
export * from "./
|
|
30
|
-
export * from "./
|
|
31
|
-
export * from "./
|
|
32
|
-
export * from "./
|
|
33
|
-
export * from "./
|
|
34
|
-
export * from "./
|
|
35
|
-
export * from "./
|
|
36
|
-
export * from "./
|
|
37
|
-
export * from "./
|
|
38
|
-
export * from "./
|
|
39
|
-
export * from "./
|
|
40
|
-
export * from "./
|
|
41
|
-
export * from "./
|
|
1
|
+
// Types only, deliberately. This barrel re-exports all 40 devkit facets, so a single *value* import
|
|
2
|
+
// from it loads every one of them — measured at 417MB / 654ms, versus 38MB / 38ms for the one subpath
|
|
3
|
+
// the caller actually needed. That is how `commandDecorators/command.ts` silently put ink, typescript,
|
|
4
|
+
// ssh2, @trapezedev/project, @langchain/* and the cloud stack into every process that registered a CLI
|
|
5
|
+
// command, and it is the single largest regression this package can suffer from a one-line edit.
|
|
6
|
+
//
|
|
7
|
+
// `export type *` erases at runtime (verified on Bun 1.3.14: the re-exported module is never loaded),
|
|
8
|
+
// so `import type { App } from "@akanjs/devkit"` stays free and readable while a value import fails to
|
|
9
|
+
// compile with a self-explaining error:
|
|
10
|
+
//
|
|
11
|
+
// TS1362: 'FileSys' cannot be used as a value because it was exported using 'export type'.
|
|
12
|
+
//
|
|
13
|
+
// Import values from the owning facet instead: `import { FileSys } from "@akanjs/devkit/fileSys"`.
|
|
14
|
+
// Keep this file type-only; do not "fix" a TS1362 by widening an entry back to `export *`.
|
|
15
|
+
export type * from "./aiEditor";
|
|
16
|
+
export type * from "./akanApp";
|
|
17
|
+
export type * from "./akanConfig";
|
|
18
|
+
export type * from "./akanContext";
|
|
19
|
+
export type * from "./akanMcpContract";
|
|
20
|
+
export type * from "./applicationBuildReporter";
|
|
21
|
+
export type * from "./applicationBuildRunner";
|
|
22
|
+
export type * from "./applicationReleasePackager";
|
|
23
|
+
export type * from "./applicationTestPreload";
|
|
24
|
+
export type * from "./artifact";
|
|
25
|
+
export type * from "./builder";
|
|
26
|
+
export type * from "./capacitorApp";
|
|
27
|
+
export type * from "./cloud";
|
|
28
|
+
export type * from "./commandDecorators";
|
|
29
|
+
export type * from "./createTunnel";
|
|
30
|
+
export type * from "./dependencyScanner";
|
|
31
|
+
export type * from "./executors";
|
|
32
|
+
export type * from "./extractDeps";
|
|
33
|
+
export type * from "./fileSys";
|
|
34
|
+
export type * from "./frontendBuild";
|
|
35
|
+
export type * from "./getCredentials";
|
|
36
|
+
export type * from "./getDirname";
|
|
37
|
+
export type * from "./getModelFileData";
|
|
38
|
+
export type * from "./getRelatedCnsts";
|
|
39
|
+
export type * from "./guideline";
|
|
40
|
+
export type * from "./incrementalBuilder";
|
|
41
|
+
export type * from "./mobile";
|
|
42
|
+
export type * from "./prompter";
|
|
43
|
+
export type * from "./qualityScanner";
|
|
44
|
+
export type * from "./scanInfo";
|
|
45
|
+
export type * from "./selectModel";
|
|
46
|
+
export type * from "./spinner";
|
|
47
|
+
export type * from "./streamAi";
|
|
48
|
+
export type * from "./transforms";
|
|
49
|
+
export type * from "./typeChecker";
|
|
50
|
+
export type * from "./types";
|
|
51
|
+
export type * from "./ui";
|
|
52
|
+
export type * from "./uploadRelease";
|
|
53
|
+
export type * from "./useStdoutDimensions";
|
|
54
|
+
export type * from "./workflow";
|
|
@@ -4,6 +4,7 @@ import { DevStabilityHarness } from "./devStabilityHarness";
|
|
|
4
4
|
|
|
5
5
|
const integrationEnabled = process.env.AKAN_DEV_STABILITY_INTEGRATION === "1";
|
|
6
6
|
const INTEGRATION_TIMEOUT_MS = 120_000;
|
|
7
|
+
const MB = 1024 * 1024;
|
|
7
8
|
const harnesses: DevStabilityHarness[] = [];
|
|
8
9
|
|
|
9
10
|
const integrationTest = (name: string, fn: () => Promise<void>): void => {
|
|
@@ -404,3 +405,100 @@ export class FixtureService extends serve("fixture" as const, { serverMode: "bat
|
|
|
404
405
|
expect(manualSmoke).toHaveLength(2);
|
|
405
406
|
});
|
|
406
407
|
});
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Resource budgets for `akan start`. A dev sandbox holds this process tree for a whole session, so
|
|
411
|
+
* every regression here multiplies by the number of tenants. Budgets are deliberately loose (they
|
|
412
|
+
* carry headroom over the measured values) — they exist to catch a *reintroduced* eager import or an
|
|
413
|
+
* unbounded per-save ratchet, not to pin exact numbers. Raising one should be a visible diff.
|
|
414
|
+
*
|
|
415
|
+
* Deliberately two tests, not one per property: each boots a full dev server, and four boots after
|
|
416
|
+
* the eleven tests above pushed cold boot from 3s to 21-55s through sheer machine contention, which
|
|
417
|
+
* silently exhausted the waits and looked like product failures. Both tests therefore assert several
|
|
418
|
+
* related properties against a single boot, with explicit generous waits. Run the block on its own
|
|
419
|
+
* (`-t "dev resource budgets"`) when timing matters.
|
|
420
|
+
*/
|
|
421
|
+
describe("dev resource budgets", () => {
|
|
422
|
+
const BOOT_MS = 150_000;
|
|
423
|
+
const WAIT_MS = 90_000;
|
|
424
|
+
const budgetTest = (name: string, fn: () => Promise<void>): void => {
|
|
425
|
+
if (integrationEnabled) test(name, fn, 300_000);
|
|
426
|
+
else test.skip(name, fn);
|
|
427
|
+
};
|
|
428
|
+
|
|
429
|
+
budgetTest("builds the dev CSR artifact only once a request needs it, then keeps it in sync", async () => {
|
|
430
|
+
const harness = await createHarness();
|
|
431
|
+
const host = await harness.startHost({ timeoutMs: BOOT_MS });
|
|
432
|
+
const port = await harness.resolvePort();
|
|
433
|
+
|
|
434
|
+
// A full minified browser-target build of every page, only reachable via `/__csr` and `?csr=true`.
|
|
435
|
+
expect(host.logs.join("")).not.toMatch(/\[csr-build\] output ->/);
|
|
436
|
+
|
|
437
|
+
// Mobile local dev points a device WebView at this URL, so it must serve HTML, not a 404. Wait
|
|
438
|
+
// for the app to actually serve first: `backend ready` fires before the gateway routes to the
|
|
439
|
+
// replica, and a too-early request 503s without reaching the router that arms CSR.
|
|
440
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
441
|
+
const armMark = host.markLog();
|
|
442
|
+
const res = await fetch(`http://127.0.0.1:${port}/?csr=true`);
|
|
443
|
+
expect(res.status).toBe(200);
|
|
444
|
+
expect(await res.text()).toContain("<html");
|
|
445
|
+
expect(host.logs.join("").slice(armMark)).toMatch(/csr-build ok on demand/);
|
|
446
|
+
|
|
447
|
+
// Armed: from here on every save rebuilds CSR, which is what keeps a live mobile session working.
|
|
448
|
+
//
|
|
449
|
+
// The settle wait is not padding. Bun's recursive `fs.watch` on macOS silently drops a file event
|
|
450
|
+
// that lands in the same FSEvents coalescing window as a burst of writes elsewhere in the tree, and
|
|
451
|
+
// the CSR build above emits exactly such a burst into `.akan/artifact/csr`. Saving immediately after
|
|
452
|
+
// the request returns therefore loses the event 100% of the time — a Bun bug this test must not
|
|
453
|
+
// depend on. See `local/optimize-resource/06-watcher-dropped-event.md` for the 30-line repro.
|
|
454
|
+
await Bun.sleep(500);
|
|
455
|
+
const resyncMark = host.markLog();
|
|
456
|
+
await harness.replaceText("ui/ClientMarker.tsx", "initial-client-marker", "csr-armed-marker");
|
|
457
|
+
await host.waitForLogSince(resyncMark, /csr-rebundle ok/, WAIT_MS);
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
budgetTest("bounds the rsc worker and the tree across repeated saves", async () => {
|
|
461
|
+
const harness = await createHarness();
|
|
462
|
+
const host = await harness.startHost({
|
|
463
|
+
// Recycle on the second reload so this needs a couple of saves rather than the ten a
|
|
464
|
+
// default-threshold run would take, and take the burst-coalescing floor out of the equation.
|
|
465
|
+
timeoutMs: BOOT_MS,
|
|
466
|
+
env: { AKAN_RSC_WORKER_MAX_RELOADS: "1", AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS: "1" },
|
|
467
|
+
});
|
|
468
|
+
await harness.waitForHttpText("initial-client-marker", WAIT_MS);
|
|
469
|
+
|
|
470
|
+
const idleTotal = await DevStabilityHarness.processTreeRssBytes(host.proc.pid);
|
|
471
|
+
const idleWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, { excludeBuilder: true });
|
|
472
|
+
// Measured ~670MB for this fixture; the headroom covers machine variance, not a reintroduced
|
|
473
|
+
// eager import (the cheapest of those is ~30MB, and the devkit barrel cycle was 236MB).
|
|
474
|
+
expect(idleTotal).toBeLessThan(1_000 * MB);
|
|
475
|
+
|
|
476
|
+
const start = host.markLog();
|
|
477
|
+
for (let i = 1; i <= 3; i++) {
|
|
478
|
+
const mark = host.markLog();
|
|
479
|
+
await harness.replaceText("ui/ClientMarker.tsx", /marker(-\d+)?/, `marker-${i}`);
|
|
480
|
+
await host.waitForLogSince(mark, /pages-rebundle ok/, WAIT_MS);
|
|
481
|
+
// CSR was never requested in this fixture, so no save may pay for a CSR rebuild.
|
|
482
|
+
const afterSave = host.logs.join("").slice(mark);
|
|
483
|
+
expect(afterSave).toMatch(/csr-rebundle skipped/);
|
|
484
|
+
expect(afterSave).not.toMatch(/csr-rebundle ok/);
|
|
485
|
+
// The debounced CSS rebuild is still writing after `pages-rebundle ok`, and Bun drops a watcher
|
|
486
|
+
// event that lands in the same window as a write burst (`06-watcher-dropped-event.md`). Saving
|
|
487
|
+
// again before this save's last artifact write lands loses the next edit outright, which showed
|
|
488
|
+
// up as this test hanging for the full 90s wait on iteration 2.
|
|
489
|
+
await host.waitForLogSince(mark, /css-rebuild checked/, WAIT_MS);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
// Each in-place reload re-imports the pages bundle under a fresh `?v=`, and Bun's ESM registry
|
|
493
|
+
// never evicts — so without a recycle the worker grows for the life of the process.
|
|
494
|
+
await host.waitForLogSince(start, /rolling recycle worker reason=pages-reload-accumulation/, WAIT_MS);
|
|
495
|
+
|
|
496
|
+
// The dev host, gateway, replica and rsc worker must all stay flat across saves. The builder is
|
|
497
|
+
// still expected to grow — `Bun.build` retains native arenas that no GC reclaims, which the
|
|
498
|
+
// bounded-builder work addresses — so it is excluded here rather than silently tolerated.
|
|
499
|
+
const afterWithoutBuilder = await DevStabilityHarness.processTreeRssBytes(host.proc.pid, {
|
|
500
|
+
excludeBuilder: true,
|
|
501
|
+
});
|
|
502
|
+
expect(afterWithoutBuilder - idleWithoutBuilder).toBeLessThan(120 * MB);
|
|
503
|
+
});
|
|
504
|
+
});
|
|
@@ -252,7 +252,13 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
252
252
|
await rm(this.appDir, { recursive: true, force: true });
|
|
253
253
|
}
|
|
254
254
|
|
|
255
|
-
async startHost(
|
|
255
|
+
async startHost({
|
|
256
|
+
timeoutMs = DEFAULT_TIMEOUT_MS,
|
|
257
|
+
env = {},
|
|
258
|
+
}: {
|
|
259
|
+
timeoutMs?: number;
|
|
260
|
+
env?: Record<string, string>;
|
|
261
|
+
} = {}): Promise<DevStabilityHost> {
|
|
256
262
|
const logs: string[] = [];
|
|
257
263
|
const proc = Bun.spawn(["bash", "-lc", `bun run akan start ${JSON.stringify(this.appName)}`], {
|
|
258
264
|
cwd: this.workspaceRoot,
|
|
@@ -264,6 +270,7 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
264
270
|
AKAN_PUBLIC_LOG_LEVEL: "verbose",
|
|
265
271
|
NODE_NO_WARNINGS: "1",
|
|
266
272
|
PORT_OFFSET: String(this.portOffset),
|
|
273
|
+
...env,
|
|
267
274
|
},
|
|
268
275
|
stdout: "pipe",
|
|
269
276
|
stderr: "pipe",
|
|
@@ -292,9 +299,14 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
292
299
|
waitForLog: (pattern, waitMs) => waitForLog(logs, pattern, waitMs),
|
|
293
300
|
waitForLogSince: (mark, pattern, waitMs) => waitForLogSince(logs, mark, pattern, waitMs),
|
|
294
301
|
stop: async () => {
|
|
295
|
-
proc
|
|
302
|
+
// The host runs under `bash -lc`, so killing `proc` only kills the shell: the dev host, its
|
|
303
|
+
// builder and its backend outlive it as orphans that keep watching a deleted fixture app and
|
|
304
|
+
// interfere with later tests. Collect the descendants first, then signal all of them.
|
|
305
|
+
const pids = await DevStabilityHarness.descendantPids(proc.pid);
|
|
306
|
+
DevStabilityHarness.#signalPids(pids, "SIGTERM");
|
|
296
307
|
await Promise.race([proc.exited.catch(() => undefined), wait(3_000)]);
|
|
297
|
-
|
|
308
|
+
DevStabilityHarness.#signalPids(await DevStabilityHarness.descendantPids(proc.pid), "SIGKILL");
|
|
309
|
+
DevStabilityHarness.#signalPids(pids, "SIGKILL");
|
|
298
310
|
},
|
|
299
311
|
};
|
|
300
312
|
this.#host = host;
|
|
@@ -441,6 +453,75 @@ export const dictionary = serviceDictionary(["en", "ko"])
|
|
|
441
453
|
);
|
|
442
454
|
return 8282 + Math.max(apps.indexOf(this.appName), 0) + this.portOffset;
|
|
443
455
|
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Resident set size of the whole `akan start` process tree, which is what a dev sandbox actually
|
|
459
|
+
* costs: supervisor, builder, gateway, replica and rsc worker. `excludeBuilder` drops the bundler
|
|
460
|
+
* process, whose growth across saves is `Bun.build` native arena retention rather than a leak the
|
|
461
|
+
* other processes could be blamed for.
|
|
462
|
+
*/
|
|
463
|
+
static async processTreeRssBytes(
|
|
464
|
+
rootPid: number,
|
|
465
|
+
{ excludeBuilder = false }: { excludeBuilder?: boolean } = {},
|
|
466
|
+
): Promise<number> {
|
|
467
|
+
const rows = await DevStabilityHarness.#psRows();
|
|
468
|
+
const pids = DevStabilityHarness.#collectDescendants(rows, rootPid);
|
|
469
|
+
return (
|
|
470
|
+
rows
|
|
471
|
+
.filter((row) => pids.has(row.pid))
|
|
472
|
+
// `bun run akan …` is the npm-script shell wrapper, not a dev process.
|
|
473
|
+
.filter((row) => !row.cmd.startsWith("bash -lc") && !row.cmd.includes("cli/build.ts"))
|
|
474
|
+
.filter((row) => !excludeBuilder || !row.cmd.includes("incrementalBuilder"))
|
|
475
|
+
.reduce((total, row) => total + row.rssKb * 1024, 0)
|
|
476
|
+
);
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/** Pids of `rootPid` and everything under it, deepest first, so callers can signal children before parents. */
|
|
480
|
+
static async descendantPids(rootPid: number | undefined): Promise<number[]> {
|
|
481
|
+
if (!rootPid) return [];
|
|
482
|
+
const pids = DevStabilityHarness.#collectDescendants(await DevStabilityHarness.#psRows(), rootPid);
|
|
483
|
+
return [...pids].reverse();
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
static async #psRows(): Promise<Array<{ pid: number; ppid: number; rssKb: number; cmd: string }>> {
|
|
487
|
+
const output = await Bun.$`ps -eo pid,ppid,rss,command`.text().catch(() => "");
|
|
488
|
+
return output
|
|
489
|
+
.split("\n")
|
|
490
|
+
.slice(1)
|
|
491
|
+
.flatMap((line) => {
|
|
492
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
493
|
+
return match
|
|
494
|
+
? [{ pid: Number(match[1]), ppid: Number(match[2]), rssKb: Number(match[3]), cmd: match[4] ?? "" }]
|
|
495
|
+
: [];
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
static #collectDescendants(rows: Array<{ pid: number; ppid: number }>, rootPid: number): Set<number> {
|
|
500
|
+
// Iterate to a fixpoint rather than a fixed depth: the tree is `bash -lc` -> `bun run` -> the `&&`
|
|
501
|
+
// shell -> dev host -> gateway -> replica -> rsc worker, and a pass only guarantees one new
|
|
502
|
+
// generation when `ps` happens to list parents before children. A capped walk silently leaves the
|
|
503
|
+
// deepest processes unsignalled, which is how orphaned dev hosts survived `stop()`.
|
|
504
|
+
const pids = new Set([rootPid]);
|
|
505
|
+
for (let added = 1; added > 0; ) {
|
|
506
|
+
added = 0;
|
|
507
|
+
for (const row of rows)
|
|
508
|
+
if (pids.has(row.ppid) && !pids.has(row.pid)) {
|
|
509
|
+
pids.add(row.pid);
|
|
510
|
+
added++;
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
return pids;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
static #signalPids(pids: number[], signal: NodeJS.Signals): void {
|
|
517
|
+
for (const pid of pids) {
|
|
518
|
+
try {
|
|
519
|
+
process.kill(pid, signal);
|
|
520
|
+
} catch {
|
|
521
|
+
// Already exited, or reaped between the `ps` snapshot and here.
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
}
|
|
444
525
|
}
|
|
445
526
|
|
|
446
527
|
export async function waitForHmrMessageSince(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.4.
|
|
3
|
+
"version": "2.4.1-rc.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -23,7 +23,19 @@
|
|
|
23
23
|
"default": "./index.ts"
|
|
24
24
|
},
|
|
25
25
|
"./package.json": "./package.json",
|
|
26
|
-
"
|
|
26
|
+
"./akanApp": "./akanApp/index.ts",
|
|
27
|
+
"./akanConfig": "./akanConfig/index.ts",
|
|
28
|
+
"./artifact": "./artifact/index.ts",
|
|
29
|
+
"./cloud": "./cloud/index.ts",
|
|
30
|
+
"./commandDecorators": "./commandDecorators/index.ts",
|
|
31
|
+
"./frontendBuild": "./frontendBuild/index.ts",
|
|
32
|
+
"./incrementalBuilder": "./incrementalBuilder/index.ts",
|
|
33
|
+
"./mobile": "./mobile/index.ts",
|
|
34
|
+
"./transforms": "./transforms/index.ts",
|
|
35
|
+
"./ui": "./ui/index.ts",
|
|
36
|
+
"./workflow": "./workflow/index.ts",
|
|
37
|
+
"./*.ts": "./*.ts",
|
|
38
|
+
"./*": "./*.ts"
|
|
27
39
|
},
|
|
28
40
|
"dependencies": {
|
|
29
41
|
"@inquirer/prompts": "^8.4.3",
|
|
@@ -32,7 +44,7 @@
|
|
|
32
44
|
"@langchain/openai": "^1.4.6",
|
|
33
45
|
"@tailwindcss/node": "^4.3.0",
|
|
34
46
|
"@trapezedev/project": "^7.1.4",
|
|
35
|
-
"akanjs": "2.4.
|
|
47
|
+
"akanjs": "2.4.1-rc.1",
|
|
36
48
|
"chalk": "^5.6.2",
|
|
37
49
|
"commander": "^14.0.3",
|
|
38
50
|
"daisyui": "5.5.23",
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import ts from "typescript";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Static enforcement of the `page/` route conventions, split out of `executors.ts` so that importing
|
|
5
|
+
* an executor does not pull `typescript` (+65MB resident) into the module graph. Both validators need
|
|
6
|
+
* a real AST — modifier inspection, `export *` rejection, statement-kind walking and call-expression
|
|
7
|
+
* identity — so `Bun.Transpiler.scan()`, which only reports export names, cannot replace them.
|
|
8
|
+
*
|
|
9
|
+
* Import it dynamically (`await import("./routeSourceValidator")`) so long-lived processes that never
|
|
10
|
+
* validate a route source stay lean.
|
|
11
|
+
*/
|
|
12
|
+
export class RouteSourceValidator {
|
|
13
|
+
static readonly #pageExports = new Set([
|
|
14
|
+
"default",
|
|
15
|
+
"pageConfig",
|
|
16
|
+
"head",
|
|
17
|
+
"metadata",
|
|
18
|
+
"generateHead",
|
|
19
|
+
"generateMetadata",
|
|
20
|
+
"Loading",
|
|
21
|
+
]);
|
|
22
|
+
static readonly #rootLayoutExports = new Set([
|
|
23
|
+
"default",
|
|
24
|
+
"pageConfig",
|
|
25
|
+
"head",
|
|
26
|
+
"metadata",
|
|
27
|
+
"generateHead",
|
|
28
|
+
"generateMetadata",
|
|
29
|
+
"fonts",
|
|
30
|
+
"manifest",
|
|
31
|
+
"theme",
|
|
32
|
+
"reconnect",
|
|
33
|
+
"layoutStyle",
|
|
34
|
+
"gaTrackingId",
|
|
35
|
+
"Loading",
|
|
36
|
+
"NotFound",
|
|
37
|
+
"Error",
|
|
38
|
+
]);
|
|
39
|
+
static readonly #layoutExports = new Set([
|
|
40
|
+
"default",
|
|
41
|
+
"pageConfig",
|
|
42
|
+
"head",
|
|
43
|
+
"metadata",
|
|
44
|
+
"generateHead",
|
|
45
|
+
"generateMetadata",
|
|
46
|
+
"Loading",
|
|
47
|
+
"NotFound",
|
|
48
|
+
"Error",
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
static validateRouteSourceExports(
|
|
52
|
+
source: string,
|
|
53
|
+
filePath: string,
|
|
54
|
+
kind: "page" | "layout",
|
|
55
|
+
options: { rootLayout?: boolean } = {},
|
|
56
|
+
) {
|
|
57
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
58
|
+
const allowed =
|
|
59
|
+
kind === "page"
|
|
60
|
+
? RouteSourceValidator.#pageExports
|
|
61
|
+
: options.rootLayout
|
|
62
|
+
? RouteSourceValidator.#rootLayoutExports
|
|
63
|
+
: RouteSourceValidator.#layoutExports;
|
|
64
|
+
const exported = new Set<string>();
|
|
65
|
+
const assertExport = (name: string) => {
|
|
66
|
+
if (!allowed.has(name)) {
|
|
67
|
+
throw new Error(`[route-convention] unsupported export "${name}" in ${filePath}`);
|
|
68
|
+
}
|
|
69
|
+
exported.add(name);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
for (const statement of sourceFile.statements) {
|
|
73
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) continue;
|
|
74
|
+
if (ts.isExportDeclaration(statement)) {
|
|
75
|
+
if (statement.isTypeOnly) continue;
|
|
76
|
+
const clause = statement.exportClause;
|
|
77
|
+
if (!clause) throw new Error(`[route-convention] export * is not allowed in route modules: ${filePath}`);
|
|
78
|
+
if (ts.isNamedExports(clause)) {
|
|
79
|
+
for (const element of clause.elements) {
|
|
80
|
+
if (element.isTypeOnly) continue;
|
|
81
|
+
assertExport(element.name.text);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
const modifiers = ts.canHaveModifiers(statement) ? ts.getModifiers(statement) : undefined;
|
|
87
|
+
const isExported = modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword) ?? false;
|
|
88
|
+
if (!isExported) continue;
|
|
89
|
+
const isDefault = modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword) ?? false;
|
|
90
|
+
if (isDefault) {
|
|
91
|
+
assertExport("default");
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (ts.isVariableStatement(statement)) {
|
|
95
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
96
|
+
if (ts.isIdentifier(declaration.name)) assertExport(declaration.name.text);
|
|
97
|
+
}
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const name = (statement as unknown as { name?: ts.Node }).name;
|
|
101
|
+
if (name && ts.isIdentifier(name)) {
|
|
102
|
+
assertExport(name.text);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (exported.has("head") && exported.has("generateHead")) {
|
|
106
|
+
throw new Error(`[route-convention] head and generateHead cannot both be exported in ${filePath}`);
|
|
107
|
+
}
|
|
108
|
+
if (
|
|
109
|
+
!options.rootLayout &&
|
|
110
|
+
(exported.has("head") || exported.has("generateHead")) &&
|
|
111
|
+
(exported.has("metadata") || exported.has("generateMetadata"))
|
|
112
|
+
) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
`[route-convention] head/generateHead and metadata/generateMetadata cannot both be exported in ${filePath}`,
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
if (exported.has("metadata") && exported.has("generateMetadata")) {
|
|
118
|
+
throw new Error(`[route-convention] metadata and generateMetadata cannot both be exported in ${filePath}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Statically enforces that a `_overrides.tsx` route file is a logic-free activation manifest: a plain module
|
|
124
|
+
* (no `"use client"` — the framework generates the client wrapper) that only imports components and binds them
|
|
125
|
+
* to slots through a single `export default override({ Modal: BrandModal })`. It must not declare components
|
|
126
|
+
* inline or run logic — that keeps the override contract a thin binding layer rather than a second place to
|
|
127
|
+
* author UI. Slot names and value types are validated at compile time by `override`.
|
|
128
|
+
*/
|
|
129
|
+
static validateOverridesSourceExports(source: string, filePath: string) {
|
|
130
|
+
const sourceFile = ts.createSourceFile(filePath, source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
|
|
131
|
+
const fail = (message: string): never => {
|
|
132
|
+
throw new Error(`[route-convention] ${message}: ${filePath}`);
|
|
133
|
+
};
|
|
134
|
+
let defaultOverride: ts.ExportAssignment | null = null;
|
|
135
|
+
for (const statement of sourceFile.statements) {
|
|
136
|
+
// A "use client" directive is unnecessary (the framework wraps the manifest) but harmless if present.
|
|
137
|
+
if (ts.isExpressionStatement(statement) && ts.isStringLiteral(statement.expression)) continue;
|
|
138
|
+
// The manifest imports the app components it binds; imports and type-only decls carry no runtime logic.
|
|
139
|
+
if (ts.isImportDeclaration(statement)) continue;
|
|
140
|
+
if (ts.isInterfaceDeclaration(statement) || ts.isTypeAliasDeclaration(statement)) continue;
|
|
141
|
+
if (ts.isExportAssignment(statement) && !statement.isExportEquals) {
|
|
142
|
+
defaultOverride = statement;
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
fail(`_overrides.tsx may only contain imports and a single "export default override({ ... })"`);
|
|
146
|
+
}
|
|
147
|
+
if (!defaultOverride) return fail(`_overrides.tsx must "export default override({ ... })"`);
|
|
148
|
+
const expression = defaultOverride.expression;
|
|
149
|
+
if (
|
|
150
|
+
!ts.isCallExpression(expression) ||
|
|
151
|
+
!ts.isIdentifier(expression.expression) ||
|
|
152
|
+
expression.expression.text !== "override"
|
|
153
|
+
)
|
|
154
|
+
fail(
|
|
155
|
+
`_overrides.tsx default export must be a call to "override", e.g. "export default override({ Modal: BrandModal })"`,
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
}
|
package/scanInfo.ts
CHANGED
|
@@ -10,7 +10,6 @@ import type {
|
|
|
10
10
|
ScanResult,
|
|
11
11
|
} from "./akanConfig";
|
|
12
12
|
|
|
13
|
-
import { TypeScriptDependencyScanner } from "./dependencyScanner";
|
|
14
13
|
import { AppExecutor, LibExecutor, PkgExecutor, WorkspaceExecutor } from "./executors";
|
|
15
14
|
|
|
16
15
|
const scalarFileTypes = ["constant", "dictionary", "document", "template", "unit", "util", "view", "zone"] as const;
|
|
@@ -98,6 +97,11 @@ const moduleUiFileTypes = {
|
|
|
98
97
|
const testFilePattern = /\.(test|spec)\.(ts|tsx)$/;
|
|
99
98
|
const rootSignalTestFilePattern = /^[A-Za-z][A-Za-z0-9_-]*\.signal\.(test|spec)\.(ts|tsx)$/;
|
|
100
99
|
|
|
100
|
+
// The dependency scanner needs `typescript` (~65MB resident). Only the scan/sync commands run it, so
|
|
101
|
+
// load it on demand rather than through the module graph of every process that imports scan info.
|
|
102
|
+
const createDependencyScanner = async (exec: AppExecutor | LibExecutor | PkgExecutor) =>
|
|
103
|
+
(await import("./dependencyScanner")).TypeScriptDependencyScanner.from(exec);
|
|
104
|
+
|
|
101
105
|
const isAllowedTestFile = (filename: string) => testFilePattern.test(filename);
|
|
102
106
|
const isAllowedLibRootFile = (filename: string) =>
|
|
103
107
|
libRootAllowedFiles.has(filename) || rootSignalTestFilePattern.test(filename);
|
|
@@ -241,7 +245,7 @@ class ScanInfo {
|
|
|
241
245
|
static async getScanResult(exec: AppExecutor | LibExecutor) {
|
|
242
246
|
const [akanConfig, scanner, pkgs, libs] = await Promise.all([
|
|
243
247
|
exec.getConfig(),
|
|
244
|
-
|
|
248
|
+
createDependencyScanner(exec),
|
|
245
249
|
exec.workspace.getPkgs(),
|
|
246
250
|
exec.workspace.getLibs(),
|
|
247
251
|
]);
|
|
@@ -546,7 +550,7 @@ export class PkgInfo {
|
|
|
546
550
|
|
|
547
551
|
static async scanExecutor(exec: PkgExecutor) {
|
|
548
552
|
const [tsconfig, rootPackageJson] = await Promise.all([exec.getTsConfig(), exec.workspace.getPackageJson()]);
|
|
549
|
-
const scanner = await
|
|
553
|
+
const scanner = await createDependencyScanner(exec);
|
|
550
554
|
const npmSet = new Set(Object.keys({ ...rootPackageJson.dependencies, ...rootPackageJson.devDependencies }));
|
|
551
555
|
const pkgPathSet = new Set(
|
|
552
556
|
Object.keys(tsconfig.compilerOptions.paths ?? {})
|
package/src/capacitorApp.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { AppExecutor } from "@akanjs/devkit/executors";
|
|
2
|
+
import { FileSys } from "@akanjs/devkit/fileSys";
|
|
2
3
|
import type { CapacitorConfig } from "@capacitor/cli";
|
|
3
4
|
import { MobileProject } from "@trapezedev/project";
|
|
4
5
|
import type { AndroidProject } from "@trapezedev/project/dist/android/project";
|