@akanjs/devkit 2.4.0-rc.9 → 2.4.1-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,58 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 2.4.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 23d43b3: Harden dev host recovery during failed builds:
8
+
9
+ - Defer builder/backend recycle while a generation's build is still failing
10
+ - Merge deferred invalidate batches so restarts cover every skipped change
11
+ - Recover the builder with exponential backoff instead of giving up
12
+ - Revive a backend that gave up once the build goes green again
13
+ - Resurrect dev children after a failed recycle so the error overlay stays reachable
14
+ - Enter degraded builder boot mode on compile errors and retry on the next edit
15
+ - Announce recovered pages/css state after a degraded boot succeeds
16
+
17
+ - 18abf71: Improve dev server stability:
18
+
19
+ - Add `isPortInUseError` utility for detecting EADDRINUSE across Bun versions
20
+ - Stop crash-looping replicas after max boot failures in dev mode (`akan start`)
21
+ - Handle parent IPC disconnect to prevent orphaned gateway/child processes
22
+ - Report `wsUpstream` in ready IPC so gateway routes to the actual bound port
23
+ - Fall back to ephemeral port when preferred WS port is in use
24
+ - Support controlled dev-host restart on config changes (`akan.config.ts`, `tsconfig`)
25
+ - Forward backend build-status IPC to dev host for error surfacing in HMR overlay
26
+ - Limit backend recovery attempts (5 max) and idle until next server-side edit
27
+ - Add integration tests for config-edit restart and boot-failure recovery
28
+
29
+ - 23d43b3: Improve the mobile Capacitor workflow:
30
+
31
+ - Auto-declare default Capacitor plugins in the app package.json before iOS/Android launch
32
+ - Expand mobile runtime peer dependencies and workspace-root preflight installs
33
+ - Derive repo-scoped default bundle ids to avoid Apple portal collisions
34
+ - Add `akan doctor --ios` to flag placeholder bundle identifiers
35
+ - Add `--device` to `akan start ios` for non-interactive simulator/device selection
36
+ - Prefer newer iOS runtimes and warn on SwiftUICore-incompatible simulators
37
+ - Detect SwiftUICore dyld failures with actionable guidance
38
+ - Select a routable LAN host for mobile live reload with override support
39
+ - Raise Android minSdkVersion to 26 for bundled Capacitor plugins
40
+ - Include `@capacitor-community/fcm` in push notification runtime packages
41
+ - Resolve client port from `window.location` on the browser client
42
+
43
+ ### Patch Changes
44
+
45
+ - d56a8f0: Ship Pretendard as the default font for newly created apps:
46
+
47
+ - Bundle Pretendard woff2 files under the app template `public/fonts`
48
+ - Declare `fonts` with `default: true` in the generated root `_layout.tsx`
49
+
50
+ - Updated dependencies [d56a8f0]
51
+ - Updated dependencies [23d43b3]
52
+ - Updated dependencies [18abf71]
53
+ - Updated dependencies [23d43b3]
54
+ - akanjs@2.4.0
55
+
3
56
  ## 2.3.11
4
57
 
5
58
  ### Minor Changes
@@ -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 ${this.#builder?.status ?? "stopped"}; reload after the builder is ready`,
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/capacitorApp.ts CHANGED
@@ -186,7 +186,9 @@ export const selectLocalDevHost = (
186
186
  a.name.localeCompare(b.name) ||
187
187
  a.address.localeCompare(b.address),
188
188
  );
189
- return best ? { host: best.address, source: "detected", candidates } : { host: "127.0.0.1", source: "loopback", candidates };
189
+ return best
190
+ ? { host: best.address, source: "detected", candidates }
191
+ : { host: "127.0.0.1", source: "loopback", candidates };
190
192
  };
191
193
 
192
194
  const isRecord = (value: unknown): value is Record<string, unknown> =>
@@ -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 kebabKey = camelToKebabCase(targetMeta.key);
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
- import { TypeChecker } from "./typeChecker";
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
- getTypeChecker() {
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
- if (parsed.kind === "overrides") validateOverridesSourceExports(routeSource, absPath);
1506
- else validateRouteSourceExports(routeSource, absPath, parsed.kind, { rootLayout: isRootLayout });
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();
@@ -5,6 +5,7 @@ import type { App } from "../commandDecorators";
5
5
 
6
6
  const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
7
7
  "build-route-res",
8
+ "build-csr-res",
8
9
  "builder-ready",
9
10
  "invalidate",
10
11
  "css-updated",
@@ -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
- WorkspaceExecutor,
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(`csr-rebundle skipped; set AKAN_DEV_CSR_REBUILD=1 to enable per-save CSR rebuilds`);
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
- // CSR is served by `akn start`, so rebuild dev CSR artifacts until incremental CSR HMR is implemented.
409
- return true;
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
- await new CsrArtifactBuilder(app).build();
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 build-route requests get an error response (instead of
480
- // hanging the backend) while the builder is still booting or recovering from a failed build.
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" || msg.type !== "build-route") return;
483
- if (!builder) {
484
- process.send?.({
485
- type: "build-route-res",
486
- id: msg.id,
487
- ok: false,
488
- error: "builder is recovering from a failed boot build; retry after the build error is fixed",
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
- void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
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
- export * from "./aiEditor";
2
- export * from "./akanApp";
3
- export * from "./akanConfig";
4
- export * from "./akanContext";
5
- export * from "./akanMcpContract";
6
- export * from "./applicationBuildReporter";
7
- export * from "./applicationBuildRunner";
8
- export * from "./applicationReleasePackager";
9
- export * from "./applicationTestPreload";
10
- export * from "./artifact";
11
- export * from "./builder";
12
- export * from "./capacitorApp";
13
- export * from "./cloud";
14
- export * from "./cloud";
15
- export * from "./commandDecorators";
16
- export * from "./createTunnel";
17
- export * from "./dependencyScanner";
18
- export * from "./executors";
19
- export * from "./extractDeps";
20
- export * from "./fileSys";
21
- export * from "./frontendBuild";
22
- export * from "./getCredentials";
23
- export * from "./getDirname";
24
- export * from "./getModelFileData";
25
- export * from "./getRelatedCnsts";
26
- export * from "./guideline";
27
- export * from "./incrementalBuilder";
28
- export * from "./mobile";
29
- export * from "./prompter";
30
- export * from "./qualityScanner";
31
- export * from "./scanInfo";
32
- export * from "./selectModel";
33
- export * from "./spinner";
34
- export * from "./streamAi";
35
- export * from "./transforms";
36
- export * from "./typeChecker";
37
- export * from "./types";
38
- export * from "./ui";
39
- export * from "./uploadRelease";
40
- export * from "./useStdoutDimensions";
41
- export * from "./workflow";
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(timeoutMs = DEFAULT_TIMEOUT_MS): Promise<DevStabilityHost> {
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.kill("SIGTERM");
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
- if (!proc.killed) proc.kill("SIGKILL");
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.0-rc.9",
3
+ "version": "2.4.1-rc.0",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.4.0-rc.9",
35
+ "akanjs": "2.4.1-rc.0",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "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
- TypeScriptDependencyScanner.from(exec),
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 TypeScriptDependencyScanner.from(exec);
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 ?? {})
@@ -1,4 +1,5 @@
1
- import { type AppExecutor, FileSys } from "@akanjs/devkit";
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";