@akanjs/cli 2.4.1-rc.2 → 2.4.1-rc.4

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.
Files changed (44) hide show
  1. package/.build-stamp +1 -0
  2. package/{agent.command-h4afc69n.js → agent.command-t4346579.js} +6 -6
  3. package/{application.command-47mj9qsy.js → application.command-zkmeemdt.js} +5 -4
  4. package/{applicationBuildRunner-yz144508.js → applicationBuildRunner-5w8j9yp7.js} +2 -1
  5. package/buildBatch.proc.js +176 -0
  6. package/{capacitorApp-y0h6cgft.js → capacitorApp-wnxzrppx.js} +2 -2
  7. package/{cloud.command-hxcgsf80.js → cloud.command-5ztqxppm.js} +9 -8
  8. package/{context.command-nqbtak4f.js → context.command-smwxwgwf.js} +14 -14
  9. package/{guideline.command-ya0dh44f.js → guideline.command-1r0esdjs.js} +3 -3
  10. package/incrementalBuilder.proc.js +281 -160
  11. package/index-0fn1r7gg.js +466 -0
  12. package/{index-qhtr07v8.js → index-0jwvs8vp.js} +19 -19
  13. package/{index-a6sbyy0b.js → index-0t7pwff2.js} +3 -0
  14. package/{index-h6ca6qg0.js → index-2v10yr8g.js} +1 -1
  15. package/{index-73pr2cmy.js → index-2v6a4wd5.js} +4 -4
  16. package/{index-45aj5ry0.js → index-3sd2vape.js} +3 -3
  17. package/{index-y3hdhy4p.js → index-ahpcr9ss.js} +5 -5
  18. package/{index-8rc0bm04.js → index-eay4t1te.js} +2 -2
  19. package/{index-qaq13qk3.js → index-eqxmxan6.js} +1 -1
  20. package/{index-pmm9e2jf.js → index-fh990y67.js} +2 -2
  21. package/{index-sgmas1fc.js → index-gr4cjv99.js} +5 -5
  22. package/{index-1xdrsbry.js → index-hk58kr1m.js} +1 -1
  23. package/{index-85msc0wg.js → index-jbtn8h1y.js} +3 -3
  24. package/{index-a5rmdgy4.js → index-khzzttv1.js} +1362 -1447
  25. package/{index-77crfweb.js → index-n5y2gbf2.js} +478 -20
  26. package/{index-n6h3482q.js → index-nv7et4t3.js} +2 -2
  27. package/{index-hwzpw9c1.js → index-pya1h7wx.js} +3 -3
  28. package/{index-hdqztm58.js → index-qpz4csbs.js} +1 -1
  29. package/{index-mq6ns0f9.js → index-s9yajzkj.js} +9 -9
  30. package/{index-8pkbzj26.js → index-wh201a76.js} +7 -7
  31. package/{index-swf4bmbg.js → index-y4kg682s.js} +1 -1
  32. package/index.js +18 -18
  33. package/{library.command-r15zdqvp.js → library.command-jrew04m6.js} +3 -3
  34. package/{localRegistry.command-6z4s13mj.js → localRegistry.command-m08dkm1b.js} +8 -7
  35. package/{module.command-qrj3kmyz.js → module.command-p98t7522.js} +6 -6
  36. package/{package.command-5x5m0ej1.js → package.command-57dyrfmn.js} +3 -3
  37. package/package.json +2 -2
  38. package/{page.command-c6xdx0xm.js → page.command-tas6f3na.js} +3 -3
  39. package/{primitive.command-pv9ssmtf.js → primitive.command-bjrbsakw.js} +7 -7
  40. package/{quality.command-es67wvdp.js → quality.command-zavaafbw.js} +2 -2
  41. package/{repair.command-677675vw.js → repair.command-je57wx67.js} +5 -5
  42. package/{scalar.command-kabkd6wd.js → scalar.command-gqqyy1kr.js} +5 -5
  43. package/{workflow.command-64r6cw0w.js → workflow.command-d6mrc47s.js} +10 -10
  44. package/{workspace.command-875aj35r.js → workspace.command-bbg96z8k.js} +24 -23
@@ -4,17 +4,21 @@ import {
4
4
  } from "./index-76rn3g2c.js";
5
5
  import {
6
6
  LibraryScript
7
- } from "./index-pmm9e2jf.js";
7
+ } from "./index-fh990y67.js";
8
8
  import {
9
9
  runner,
10
10
  script
11
- } from "./index-hdqztm58.js";
11
+ } from "./index-qpz4csbs.js";
12
12
  import {
13
13
  AppExecutor,
14
14
  LibExecutor,
15
15
  PkgExecutor,
16
16
  WorkspaceExecutor
17
- } from "./index-a6sbyy0b.js";
17
+ } from "./index-0t7pwff2.js";
18
+ import {
19
+ HmrWatcher,
20
+ WatchRootResolver
21
+ } from "./index-0fn1r7gg.js";
18
22
  import {
19
23
  __require
20
24
  } from "./index-r24hmh0q.js";
@@ -69,6 +73,7 @@ import { confirm } from "@inquirer/prompts";
69
73
  import { Logger as Logger5 } from "akanjs/common";
70
74
 
71
75
  // pkgs/@akanjs/devkit/akanApp/akanApp.host.ts
76
+ import { stat } from "fs/promises";
72
77
  import path2 from "path";
73
78
  import { Logger as Logger3 } from "akanjs/common";
74
79
 
@@ -209,6 +214,7 @@ var createTunnel = async (service, { app, environment, port = service === "postg
209
214
  // pkgs/@akanjs/devkit/incrementalBuilder/incrementalBuilder.host.ts
210
215
  import path from "path";
211
216
  import { Logger as Logger2 } from "akanjs/common";
217
+ import { MemoryLimit } from "akanjs/server/memoryLimit";
212
218
  var builderMsgTypeSet = new Set([
213
219
  "build-route-res",
214
220
  "build-csr-res",
@@ -216,12 +222,15 @@ var builderMsgTypeSet = new Set([
216
222
  "invalidate",
217
223
  "css-updated",
218
224
  "pages-updated",
219
- "build-status"
225
+ "build-status",
226
+ "builder-metrics"
220
227
  ]);
221
228
 
222
229
  class IncrementalBuilderHost {
223
230
  static #restartBaseDelayMs = 1000;
224
231
  static #restartMaxDelayMs = 30000;
232
+ static #recycleDrainTimeoutMs = 30000;
233
+ static #devMaxRssBytes = 1200 * 1024 * 1024;
225
234
  logger = new Logger2("IncrementalBuilderHost");
226
235
  entry;
227
236
  env;
@@ -232,7 +241,11 @@ class IncrementalBuilderHost {
232
241
  #status = "stopped";
233
242
  #restartAttempts = 0;
234
243
  #restartTimer = null;
244
+ #recycleTimer = null;
245
+ #recycleRequested = false;
246
+ #spawnAfterRecycle = false;
235
247
  #manualStop = false;
248
+ #inFlight = new Map;
236
249
  #startOptions = {};
237
250
  constructor({ app, entry, env, onMessage }) {
238
251
  this.app = app;
@@ -243,27 +256,35 @@ class IncrementalBuilderHost {
243
256
  get status() {
244
257
  return this.#status;
245
258
  }
259
+ get pid() {
260
+ return this.#proc?.pid ?? null;
261
+ }
246
262
  start(options = {}) {
247
263
  if (this.#proc)
248
264
  this.stop();
249
265
  this.#manualStop = false;
250
266
  this.#startOptions = options;
267
+ this.#spawnAfterRecycle = options.announceBootState ?? false;
251
268
  this.#spawn(false);
252
269
  return this;
253
270
  }
254
271
  #spawn(isRestart) {
255
272
  this.#status = isRestart ? "restarting" : "starting";
256
273
  this.ready = false;
274
+ const afterRecycle = this.#spawnAfterRecycle;
275
+ this.#spawnAfterRecycle = false;
257
276
  let proc;
258
277
  proc = Bun.spawn(["bun", this.entry], {
259
278
  cwd: this.app.cwdPath,
260
- env: { ...this.env, AKAN_WATCH: "1" },
279
+ env: { ...this.env, AKAN_WATCH: "1", ...afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {} },
261
280
  stdio: ["ignore", "inherit", "inherit"],
262
281
  ipc: (msg) => {
263
282
  if (this.#proc !== proc)
264
283
  return;
265
284
  if (!msg || typeof msg !== "object")
266
285
  return;
286
+ if (msg.type === "build-route-res" || msg.type === "build-csr-res")
287
+ this.#inFlight.delete(msg.id);
267
288
  if (builderMsgTypeSet.has(msg.type))
268
289
  this.#onMessage(msg);
269
290
  if (msg.type === "builder-ready" && !this.ready) {
@@ -282,7 +303,10 @@ class IncrementalBuilderHost {
282
303
  return;
283
304
  this.#proc = null;
284
305
  const wasReady = this.ready;
306
+ const wasRecycle = this.#recycleRequested;
307
+ this.#clearRecycle();
285
308
  this.ready = false;
309
+ this.#failInFlight(wasRecycle ? "builder exited to release bundler memory before answering; reload to retry" : "builder exited unexpectedly before answering; reload once it is back");
286
310
  if (this.#manualStop || this.#status === "stopped")
287
311
  return;
288
312
  if (!wasReady) {
@@ -290,6 +314,12 @@ class IncrementalBuilderHost {
290
314
  this.#startOptions.onExit?.();
291
315
  return;
292
316
  }
317
+ if (wasRecycle) {
318
+ this.logger.verbose("builder exited for a recycle; spawning its replacement now");
319
+ this.#spawnAfterRecycle = true;
320
+ this.#spawn(true);
321
+ return;
322
+ }
293
323
  this.#scheduleRestart();
294
324
  }
295
325
  });
@@ -311,6 +341,40 @@ class IncrementalBuilderHost {
311
341
  this.#spawn(true);
312
342
  }, delay);
313
343
  }
344
+ recycle(reason) {
345
+ if (!this.#proc || this.#status !== "ready" || this.#recycleRequested)
346
+ return false;
347
+ const proc = this.#proc;
348
+ if (!this.send({ type: "builder-shutdown", reason }))
349
+ return false;
350
+ this.#recycleRequested = true;
351
+ this.logger.info(`recycling builder pid=${proc.pid} (${reason})`);
352
+ this.#recycleTimer = setTimeout(() => {
353
+ this.#recycleTimer = null;
354
+ if (this.#proc !== proc)
355
+ return;
356
+ this.logger.warn(`builder pid=${proc.pid} did not exit within ${IncrementalBuilderHost.#recycleDrainTimeoutMs}ms of the recycle request; killing it`);
357
+ proc.kill();
358
+ }, IncrementalBuilderHost.#recycleDrainTimeoutMs);
359
+ return true;
360
+ }
361
+ #clearRecycle() {
362
+ this.#recycleRequested = false;
363
+ if (!this.#recycleTimer)
364
+ return;
365
+ clearTimeout(this.#recycleTimer);
366
+ this.#recycleTimer = null;
367
+ }
368
+ static maxRssBytes() {
369
+ if (process.env.AKAN_BUILDER_MAX_RSS_MB === "0")
370
+ return null;
371
+ return MemoryLimit.resolveMaxRssBytes({
372
+ megabytesEnv: "AKAN_BUILDER_MAX_RSS_MB",
373
+ bytesEnv: "AKAN_BUILDER_MAX_RSS",
374
+ limitFraction: 0.35,
375
+ fallbackBytes: IncrementalBuilderHost.#devMaxRssBytes
376
+ });
377
+ }
314
378
  send(message) {
315
379
  if (!this.#proc || this.#status !== "ready") {
316
380
  this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
@@ -318,14 +382,31 @@ class IncrementalBuilderHost {
318
382
  }
319
383
  try {
320
384
  this.#proc.send(message);
385
+ if (message.type === "build-route" || message.type === "build-csr")
386
+ this.#inFlight.set(message.id, message.type);
321
387
  return true;
322
388
  } catch (error) {
323
389
  this.logger.warn(`failed to send ${message.type} to builder: ${error instanceof Error ? error.message : String(error)}`);
324
390
  return false;
325
391
  }
326
392
  }
393
+ #failInFlight(reason) {
394
+ if (!this.#inFlight.size)
395
+ return;
396
+ const lost = [...this.#inFlight];
397
+ this.#inFlight.clear();
398
+ this.logger.warn(`failing ${lost.length} unanswered builder request(s): ${reason}`);
399
+ for (const [id, type] of lost) {
400
+ if (type === "build-route")
401
+ this.#onMessage({ type: "build-route-res", id, ok: false, error: reason });
402
+ else
403
+ this.#onMessage({ type: "build-csr-res", id, ok: false, error: reason });
404
+ }
405
+ }
327
406
  stop() {
328
407
  this.#manualStop = true;
408
+ this.#clearRecycle();
409
+ this.#failInFlight("builder was stopped before answering");
329
410
  if (this.#restartTimer) {
330
411
  clearTimeout(this.#restartTimer);
331
412
  this.#restartTimer = null;
@@ -359,6 +440,13 @@ var BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
359
440
  var BACKEND_STDERR_TAIL_LIMIT = 40;
360
441
  var BUILDER_READY_TIMEOUT_MS = 150000;
361
442
  var BUILDER_START_MAX_ATTEMPTS = 3;
443
+ var BUILDER_RSS_RECYCLE_QUIET_MS = 750;
444
+ var BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS = 30000;
445
+ var BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT = 2;
446
+ var BUILDER_RSS_SETTLE_MS = 20000;
447
+ var BUILDER_RSS_HARD_MULTIPLE = 1.5;
448
+ var DEV_IDLE_SUSPEND_MS = 300000;
449
+ var DEV_IDLE_MIN_UPTIME_MS = 30000;
362
450
  var BUILDER_RECOVERY_BASE_DELAY_MS = 2000;
363
451
  var BUILDER_RECOVERY_MAX_DELAY_MS = 60000;
364
452
  var SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
@@ -446,6 +534,74 @@ var hasBuildFailureForGeneration = (statusByPhase, generation) => {
446
534
  }
447
535
  return false;
448
536
  };
537
+ var decideBuilderRssRecycle = ({
538
+ rssBytes,
539
+ ceilingBytes,
540
+ buildFailed,
541
+ msSinceLastRecycle,
542
+ minIntervalMs = BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS
543
+ }) => {
544
+ if (!ceilingBytes)
545
+ return "unbounded";
546
+ if (rssBytes < ceilingBytes)
547
+ return "below-ceiling";
548
+ if (buildFailed)
549
+ return "build-failed";
550
+ if (msSinceLastRecycle !== null && msSinceLastRecycle < minIntervalMs)
551
+ return "too-soon";
552
+ return "recycle";
553
+ };
554
+ var decideBuilderRssSettle = ({
555
+ rssBytes,
556
+ ceilingBytes,
557
+ hardMultiple = BUILDER_RSS_HARD_MULTIPLE
558
+ }) => rssBytes >= ceilingBytes * hardMultiple ? "recycle-now" : "wait-and-recheck";
559
+ var decideIdleSuspend = ({
560
+ enabled,
561
+ suspended,
562
+ builderReady,
563
+ backendReady,
564
+ buildFailed,
565
+ restartPending,
566
+ msSinceWake,
567
+ minUptimeMs = DEV_IDLE_MIN_UPTIME_MS
568
+ }) => {
569
+ if (!enabled)
570
+ return "disabled";
571
+ if (suspended)
572
+ return "already-suspended";
573
+ if (!builderReady)
574
+ return "builder-not-ready";
575
+ if (!backendReady)
576
+ return "backend-not-ready";
577
+ if (buildFailed)
578
+ return "build-failed";
579
+ if (restartPending)
580
+ return "restart-pending";
581
+ if (msSinceWake !== null && msSinceWake < minUptimeMs)
582
+ return "too-soon";
583
+ return "suspend";
584
+ };
585
+ var resolveIdleSuspendMs = (raw) => {
586
+ if (raw === undefined || raw === "")
587
+ return DEV_IDLE_SUSPEND_MS;
588
+ const parsed = Number(raw);
589
+ if (!Number.isFinite(parsed) || parsed <= 0)
590
+ return null;
591
+ return Math.round(parsed);
592
+ };
593
+ var hasAnyBuildFailure = (statusByPhase) => [...statusByPhase.values()].some((status) => !status.ok);
594
+ var shouldRefreshConfigOnIdleWake = (batch) => !!batch && batch.kinds.has("config");
595
+ var shouldAbandonBuilderRssCeiling = (ineffectiveRecycles, limit = BUILDER_INEFFECTIVE_RSS_RECYCLE_LIMIT) => ineffectiveRecycles >= limit;
596
+ var shouldRelayRecycledFrontendState = (current, next) => {
597
+ if (!current || current.type !== next.type)
598
+ return true;
599
+ if (current.type === "pages-updated" && next.type === "pages-updated")
600
+ return current.data.bundlePath !== next.data.bundlePath;
601
+ if (current.type === "css-updated" && next.type === "css-updated")
602
+ return JSON.stringify(current.data.cssAssets) !== JSON.stringify(next.data.cssAssets);
603
+ return true;
604
+ };
449
605
  var mergeDevPlans = (current, next) => {
450
606
  if (!current)
451
607
  return next;
@@ -484,6 +640,7 @@ class BackendImportGraph {
484
640
  #jsTranspiler = new Bun.Transpiler({ loader: "js" });
485
641
  #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
486
642
  #files = new Set;
643
+ #scanCache = new Map;
487
644
  #ready = false;
488
645
  #lastRefreshSucceeded = false;
489
646
  constructor(app, logger) {
@@ -525,11 +682,10 @@ class BackendImportGraph {
525
682
  continue;
526
683
  if (!this.#isWorkspaceSource(current, workspaceRoot))
527
684
  continue;
528
- if (!await Bun.file(current).exists())
685
+ const imports = await this.#importsOf(current);
686
+ if (!imports)
529
687
  continue;
530
688
  files.add(current);
531
- const source = await Bun.file(current).text();
532
- const imports = this.#scanImports(current, source);
533
689
  const importerDir = path2.dirname(current);
534
690
  for (const imp of imports) {
535
691
  if (!GRAPH_IMPORT_KINDS.has(imp.kind) || !imp.path || NON_SOURCE_EXT_RE.test(imp.path))
@@ -540,8 +696,23 @@ class BackendImportGraph {
540
696
  queue.push(resolved);
541
697
  }
542
698
  }
699
+ for (const cached of this.#scanCache.keys())
700
+ if (!files.has(cached))
701
+ this.#scanCache.delete(cached);
543
702
  return files;
544
703
  }
704
+ async#importsOf(file) {
705
+ const stats = await stat(file).catch(() => null);
706
+ if (!stats?.isFile())
707
+ return null;
708
+ const mtimeMs = Math.round(stats.mtimeMs);
709
+ const cached = this.#scanCache.get(file);
710
+ if (cached && cached.mtimeMs === mtimeMs && cached.size === stats.size)
711
+ return cached.specifiers;
712
+ const specifiers = this.#scanImports(file, await Bun.file(file).text());
713
+ this.#scanCache.set(file, { mtimeMs, size: stats.size, specifiers });
714
+ return specifiers;
715
+ }
545
716
  async#entrypoints() {
546
717
  const roots = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
547
718
  const existing = [];
@@ -606,10 +777,24 @@ class AkanAppHost {
606
777
  #backendBuildStatusGeneration = 0;
607
778
  #backendStderrTail = [];
608
779
  #lastGoodFrontend = {};
780
+ #rssRecycleTimer = null;
781
+ #rssRecycleReason = null;
782
+ #lastRssRecycleAtMono = null;
783
+ #rssCeilingIneffective = 0;
784
+ #rssSettleToken = 0;
785
+ #rssRecycleOver = null;
786
+ #rssCeilingAbandoned = false;
609
787
  #buildStatusByPhase = new Map;
610
788
  #pendingBuildStatusReplay = [];
611
789
  #builderMessageQueue = Promise.resolve();
612
790
  #backendGraph;
791
+ #idleSuspendTimer = null;
792
+ #suspended = false;
793
+ #waking = false;
794
+ #wokeAtMono = null;
795
+ #idleWatcher = null;
796
+ #suspendedChanges = null;
797
+ #pendingBuilderMessages = [];
613
798
  constructor(app, { env, withInk = false }) {
614
799
  this.app = app;
615
800
  this.env = env;
@@ -628,9 +813,12 @@ class AkanAppHost {
628
813
  ]);
629
814
  Object.assign(this.env, { REDIS_HOST: redisHost });
630
815
  this.#startBackend();
816
+ this.#armIdleSuspend();
631
817
  return this;
632
818
  }
633
819
  async stop() {
820
+ this.#cancelIdleSuspend();
821
+ this.#stopIdleWatcher();
634
822
  if (this.#restartTimer) {
635
823
  clearTimeout(this.#restartTimer);
636
824
  this.#restartTimer = null;
@@ -895,24 +1083,284 @@ ${this.#backendStderrTail.join(`
895
1083
  });
896
1084
  }
897
1085
  async#handleBuilderMessage(message) {
1086
+ this.#markDevActivity();
898
1087
  if (message.type === "build-status") {
899
1088
  this.#recordBuildStatus(message.data);
900
1089
  this.#sendOrQueueBuildStatus(message.data);
901
1090
  this.#reviveBackendAfterGreenBuild(message.data);
902
1091
  return;
903
1092
  }
904
- if (message.type === "pages-updated")
905
- this.#recordLastGood(message);
906
- if (message.type === "css-updated")
907
- this.#recordLastGood(message);
1093
+ if (message.type === "builder-metrics") {
1094
+ this.#handleBuilderMetrics(message.data);
1095
+ return;
1096
+ }
1097
+ if (message.type === "pages-updated" || message.type === "css-updated") {
1098
+ const recycled = message.data.reason === "builder-recycle";
1099
+ if (recycled && !this.#shouldRelayRecycledState(message))
1100
+ return;
1101
+ this.#recordLastGood(message, { supersede: recycled });
1102
+ }
908
1103
  if (message.type === "invalidate") {
909
1104
  await this.#handleInvalidate(message);
910
1105
  return;
911
1106
  }
912
1107
  this.#sendToBackend(message);
913
1108
  }
1109
+ #shouldRelayRecycledState(message) {
1110
+ const current = message.type === "pages-updated" ? this.#lastGoodFrontend.pages : this.#lastGoodFrontend.css;
1111
+ if (shouldRelayRecycledFrontendState(current, message)) {
1112
+ this.logger.verbose(`[builder-recycle] ${message.type} moved during the recycle; pushing it to the backend`);
1113
+ return true;
1114
+ }
1115
+ this.logger.verbose(`[builder-recycle] ${message.type} unchanged after the recycle; backend left as is`);
1116
+ return false;
1117
+ }
1118
+ #handleBuilderMetrics(metrics) {
1119
+ if (this.#rssCeilingAbandoned)
1120
+ return;
1121
+ const ceilingBytes = IncrementalBuilderHost.maxRssBytes();
1122
+ const asMib = (bytes) => Math.round(bytes / 1024 / 1024);
1123
+ const decision = decideBuilderRssRecycle({
1124
+ rssBytes: metrics.rssBytes,
1125
+ ceilingBytes,
1126
+ buildFailed: hasBuildFailureForGeneration(this.#buildStatusByPhase, metrics.generation),
1127
+ msSinceLastRecycle: this.#lastRssRecycleAtMono === null ? null : performance.now() - this.#lastRssRecycleAtMono
1128
+ });
1129
+ if (decision === "below-ceiling") {
1130
+ this.#rssCeilingIneffective = 0;
1131
+ return;
1132
+ }
1133
+ if (decision === "unbounded")
1134
+ return;
1135
+ if (decision === "build-failed") {
1136
+ this.logger.verbose(`[builder-recycle] deferred: generation=${metrics.generation} has a failing build, so a replacement would hit the same error`);
1137
+ return;
1138
+ }
1139
+ if (decision === "too-soon") {
1140
+ this.#rssCeilingIneffective += 1;
1141
+ if (!shouldAbandonBuilderRssCeiling(this.#rssCeilingIneffective))
1142
+ return;
1143
+ this.#rssCeilingAbandoned = true;
1144
+ this.logger.error(`[builder-recycle] the builder is still at ${asMib(metrics.rssBytes)}MiB right after being recycled, so the ${asMib(ceilingBytes ?? 0)}MiB ceiling cannot be met for this app; no longer enforcing it this session. Raise AKAN_BUILDER_MAX_RSS_MB, or set it to 0 to leave the builder unbounded.`);
1145
+ return;
1146
+ }
1147
+ this.#armRssRecycle(`rss=${asMib(metrics.rssBytes)}MiB>=${asMib(ceilingBytes ?? 0)}MiB after ${metrics.workCount} build(s)`, { rssBytes: metrics.rssBytes, ceilingBytes: ceilingBytes ?? 0 });
1148
+ }
1149
+ #armRssRecycle(reason, over) {
1150
+ if (this.#rssRecycleReason !== reason)
1151
+ this.logger.verbose(`[builder-recycle] armed (${reason}); replacing the builder once it stays quiet`);
1152
+ this.#rssRecycleReason = reason;
1153
+ if (over)
1154
+ this.#rssRecycleOver = over;
1155
+ if (this.#rssRecycleTimer)
1156
+ clearTimeout(this.#rssRecycleTimer);
1157
+ this.#rssRecycleTimer = setTimeout(() => {
1158
+ this.#rssRecycleTimer = null;
1159
+ const pendingReason = this.#rssRecycleReason;
1160
+ const pendingOver = this.#rssRecycleOver;
1161
+ this.#rssRecycleReason = null;
1162
+ this.#rssRecycleOver = null;
1163
+ if (!pendingReason)
1164
+ return;
1165
+ if (!pendingOver) {
1166
+ this.#recycleBuilderForRss(pendingReason);
1167
+ return;
1168
+ }
1169
+ this.#recycleBuilderForRssWhenStillOver(pendingReason, pendingOver);
1170
+ }, BUILDER_RSS_RECYCLE_QUIET_MS);
1171
+ }
1172
+ async#recycleBuilderForRssWhenStillOver(reason, { rssBytes, ceilingBytes }) {
1173
+ const asMib = (bytes) => Math.round(bytes / 1024 / 1024);
1174
+ if (decideBuilderRssSettle({ rssBytes, ceilingBytes }) === "recycle-now") {
1175
+ this.#recycleBuilderForRss(reason);
1176
+ return;
1177
+ }
1178
+ const pid = this.#builder?.pid;
1179
+ if (!pid) {
1180
+ this.#recycleBuilderForRss(reason);
1181
+ return;
1182
+ }
1183
+ this.#rssSettleToken += 1;
1184
+ const token = this.#rssSettleToken;
1185
+ this.logger.verbose(`[builder-recycle] holding ${Math.round(BUILDER_RSS_SETTLE_MS / 1000)}s to see whether the allocator returns it (${reason})`);
1186
+ await Bun.sleep(BUILDER_RSS_SETTLE_MS);
1187
+ if (token !== this.#rssSettleToken || this.#builder?.pid !== pid || this.#suspended || this.#waking) {
1188
+ this.logger.verbose("[builder-recycle] settle check abandoned; the builder moved on");
1189
+ return;
1190
+ }
1191
+ const settledBytes = await AkanAppHost.readProcessRssBytes(pid);
1192
+ if (settledBytes !== null && settledBytes < ceilingBytes) {
1193
+ this.logger.info(`[builder-recycle] skipped: the builder fell to ${asMib(settledBytes)}MiB (ceiling ${asMib(ceilingBytes)}MiB) on its own, so a recycle would have cost a boot build for nothing`);
1194
+ return;
1195
+ }
1196
+ this.#recycleBuilderForRss(settledBytes === null ? reason : `${reason}; still ${asMib(settledBytes)}MiB after settling`);
1197
+ }
1198
+ static async readProcessRssBytes(pid) {
1199
+ const status = await Bun.file(`/proc/${pid}/status`).text().catch(() => null);
1200
+ const vmRssKb = status === null ? null : /VmRSS:\s+(\d+) kB/.exec(status)?.[1];
1201
+ if (vmRssKb)
1202
+ return Number(vmRssKb) * 1024;
1203
+ const psOutput = await Bun.$`ps -o rss= -p ${pid}`.quiet().text().catch(() => "");
1204
+ const rssKb = Number(psOutput.trim());
1205
+ return Number.isFinite(rssKb) && rssKb > 0 ? rssKb * 1024 : null;
1206
+ }
1207
+ #cancelRssRecycle() {
1208
+ this.#rssRecycleReason = null;
1209
+ this.#rssRecycleOver = null;
1210
+ this.#rssSettleToken += 1;
1211
+ if (!this.#rssRecycleTimer)
1212
+ return;
1213
+ clearTimeout(this.#rssRecycleTimer);
1214
+ this.#rssRecycleTimer = null;
1215
+ }
1216
+ static idleSuspendMs() {
1217
+ return resolveIdleSuspendMs(process.env.AKAN_DEV_IDLE_SUSPEND_MS);
1218
+ }
1219
+ #markDevActivity() {
1220
+ if (this.#suspended || this.#waking)
1221
+ return;
1222
+ this.#armIdleSuspend();
1223
+ }
1224
+ #armIdleSuspend() {
1225
+ const idleMs = AkanAppHost.idleSuspendMs();
1226
+ this.#cancelIdleSuspend();
1227
+ if (idleMs === null)
1228
+ return;
1229
+ this.#idleSuspendTimer = setTimeout(() => {
1230
+ this.#idleSuspendTimer = null;
1231
+ this.#suspendWhenIdle(idleMs);
1232
+ }, idleMs);
1233
+ }
1234
+ #cancelIdleSuspend() {
1235
+ if (!this.#idleSuspendTimer)
1236
+ return;
1237
+ clearTimeout(this.#idleSuspendTimer);
1238
+ this.#idleSuspendTimer = null;
1239
+ }
1240
+ async#suspendWhenIdle(idleMs) {
1241
+ const decision = decideIdleSuspend({
1242
+ enabled: true,
1243
+ suspended: this.#suspended,
1244
+ builderReady: this.#builder?.status === "ready",
1245
+ backendReady: this.#backendReady,
1246
+ buildFailed: hasAnyBuildFailure(this.#buildStatusByPhase),
1247
+ restartPending: this.#restartPending,
1248
+ msSinceWake: this.#wokeAtMono === null ? null : performance.now() - this.#wokeAtMono
1249
+ });
1250
+ if (decision !== "suspend") {
1251
+ this.logger.verbose(`[idle-suspend] skipped (${decision}); re-arming`);
1252
+ this.#armIdleSuspend();
1253
+ return;
1254
+ }
1255
+ if (!await this.#startIdleWatcher()) {
1256
+ this.#armIdleSuspend();
1257
+ return;
1258
+ }
1259
+ this.#suspended = true;
1260
+ this.#stopBuilder();
1261
+ this.logger.info(`[idle-suspend] no build activity for ${Math.round(idleMs / 1000)}s; released the builder \u2014 the next edit or route request brings it back`);
1262
+ }
1263
+ get #restartPending() {
1264
+ return !!(this.#pendingRecycle || this.#restartTimer || this.#backendRecoveryTimer || this.#builderRecoveryTimer || this.#rssRecycleReason);
1265
+ }
1266
+ async#startIdleWatcher() {
1267
+ try {
1268
+ const roots = await new WatchRootResolver(this.app).resolve();
1269
+ const watcher = new HmrWatcher({
1270
+ roots,
1271
+ logger: this.logger,
1272
+ onBatch: (batch) => {
1273
+ this.#recordSuspendedChange(batch);
1274
+ this.#wakeFromIdle(`${batch.files.length} file(s) changed`);
1275
+ }
1276
+ });
1277
+ await watcher.start();
1278
+ this.#idleWatcher = watcher;
1279
+ return true;
1280
+ } catch (err) {
1281
+ this.logger.warn(`[idle-suspend] could not install the idle watcher; staying awake: ${err instanceof Error ? err.message : String(err)}`);
1282
+ this.#stopIdleWatcher();
1283
+ return false;
1284
+ }
1285
+ }
1286
+ #stopIdleWatcher() {
1287
+ this.#idleWatcher?.stop();
1288
+ this.#idleWatcher = null;
1289
+ }
1290
+ #recordSuspendedChange(batch) {
1291
+ const current = this.#suspendedChanges;
1292
+ if (!current) {
1293
+ this.#suspendedChanges = { files: [...batch.files], kinds: new Set(batch.kinds) };
1294
+ return;
1295
+ }
1296
+ this.#suspendedChanges = {
1297
+ files: [...new Set([...current.files, ...batch.files])],
1298
+ kinds: new Set([...current.kinds, ...batch.kinds])
1299
+ };
1300
+ }
1301
+ async#wakeFromIdle(reason) {
1302
+ if (!this.#suspended || this.#waking)
1303
+ return;
1304
+ this.#waking = true;
1305
+ this.#cancelIdleSuspend();
1306
+ this.#stopIdleWatcher();
1307
+ const batch = this.#suspendedChanges;
1308
+ this.#suspendedChanges = null;
1309
+ const startedAtMono = performance.now();
1310
+ this.logger.info(`[idle-suspend] waking (${reason})`);
1311
+ try {
1312
+ await this.#applyIdleWake(batch);
1313
+ this.logger.info(`[idle-suspend] awake in ${Math.round(performance.now() - startedAtMono)}ms`);
1314
+ } catch (err) {
1315
+ this.logger.error(`[idle-suspend] wake failed; recovering the builder: ${err instanceof Error ? err.message : String(err)}`);
1316
+ this.#scheduleBuilderRecovery({ files: batch?.files ?? [] });
1317
+ } finally {
1318
+ this.#suspended = false;
1319
+ this.#waking = false;
1320
+ this.#wokeAtMono = performance.now();
1321
+ this.#flushPendingBuilderMessages();
1322
+ this.#armIdleSuspend();
1323
+ }
1324
+ }
1325
+ async#applyIdleWake(batch) {
1326
+ const files = batch?.files ?? [];
1327
+ if (shouldRefreshConfigOnIdleWake(batch)) {
1328
+ this.logger.verbose("[idle-suspend] config changed while suspended; restarting the dev host");
1329
+ await this.#recycleDevChildren({ type: "invalidate", kinds: [...batch?.kinds ?? []], files }, {
1330
+ refreshConfig: true
1331
+ });
1332
+ return;
1333
+ }
1334
+ if (files.length > 0)
1335
+ await this.#backendGraph.refresh();
1336
+ await this.#startBuilder({ announceBootState: true });
1337
+ const backendFiles = files.filter((file) => this.#isBackendFile(file));
1338
+ if (backendFiles.length === 0)
1339
+ return;
1340
+ this.logger.verbose(`[idle-suspend] ${backendFiles.length} backend file(s) changed while suspended`);
1341
+ this.#scheduleBackendRestart({ files: backendFiles, roles: [] });
1342
+ }
1343
+ #flushPendingBuilderMessages() {
1344
+ const pending = this.#pendingBuilderMessages.splice(0);
1345
+ if (pending.length === 0)
1346
+ return;
1347
+ this.logger.verbose(`[idle-suspend] replaying ${pending.length} request(s) held during the wake`);
1348
+ for (const message of pending)
1349
+ this.#sendToBuilder(message);
1350
+ }
1351
+ #recycleBuilderForRss(reason) {
1352
+ if (this.#pendingRecycle || this.#restartTimer) {
1353
+ this.logger.verbose(`[builder-recycle] skipped (${reason}); a dev restart is already pending`);
1354
+ return;
1355
+ }
1356
+ if (!this.#builder?.recycle(reason))
1357
+ return;
1358
+ this.#lastRssRecycleAtMono = performance.now();
1359
+ }
914
1360
  async#handleInvalidate(message) {
915
1361
  this.#logDevPlan(message);
1362
+ if (this.#rssRecycleReason)
1363
+ this.#armRssRecycle(this.#rssRecycleReason);
916
1364
  const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
917
1365
  const pending = this.#pendingRecycle;
918
1366
  if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || pending && message.kinds.includes("code")) {
@@ -1045,15 +1493,15 @@ ${this.#backendStderrTail.join(`
1045
1493
  await this.#startBuilder();
1046
1494
  this.#startBackend({ generation, files: message.files });
1047
1495
  }
1048
- #recordLastGood(message) {
1496
+ #recordLastGood(message, { supersede = false } = {}) {
1049
1497
  if (message.type === "pages-updated") {
1050
- if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message))
1498
+ if (!supersede && !shouldReplaceLastGoodMessage(this.#lastGoodFrontend.pages, message))
1051
1499
  return;
1052
1500
  this.#lastGoodFrontend.pages = message;
1053
1501
  this.logger.verbose(`[last-good] pages generation=${message.data.generation ?? "(unknown)"} buildId=${message.data.buildId}`);
1054
1502
  return;
1055
1503
  }
1056
- if (!shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message))
1504
+ if (!supersede && !shouldReplaceLastGoodMessage(this.#lastGoodFrontend.css, message))
1057
1505
  return;
1058
1506
  this.#lastGoodFrontend.css = message;
1059
1507
  this.logger.verbose(`[last-good] css generation=${message.data.generation ?? "(unknown)"} assets=${Object.keys(message.data.cssAssets).length}`);
@@ -1132,7 +1580,9 @@ ${this.#backendStderrTail.join(`
1132
1580
  #isBackendFile(file) {
1133
1581
  return this.#backendGraph.has(file);
1134
1582
  }
1135
- async#startBuilder() {
1583
+ async#startBuilder({
1584
+ announceBootState = false
1585
+ } = {}) {
1136
1586
  const startTime = Date.now();
1137
1587
  this.app.verbose(`[cli] waiting for builder to complete initial base build\u2026`);
1138
1588
  let lastError;
@@ -1141,7 +1591,7 @@ ${this.#backendStderrTail.join(`
1141
1591
  this.#enqueueBuilderMessage(msg);
1142
1592
  });
1143
1593
  try {
1144
- await this.#waitForBuilderReady(attempt);
1594
+ await this.#waitForBuilderReady(attempt, { announceBootState });
1145
1595
  this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms \u2014 starting backend`);
1146
1596
  return this.#builder;
1147
1597
  } catch (err) {
@@ -1154,7 +1604,7 @@ ${this.#backendStderrTail.join(`
1154
1604
  }
1155
1605
  throw lastError instanceof Error ? lastError : new Error(String(lastError));
1156
1606
  }
1157
- #waitForBuilderReady(attempt) {
1607
+ #waitForBuilderReady(attempt, { announceBootState = false } = {}) {
1158
1608
  return new Promise((resolve, reject) => {
1159
1609
  if (!this.#builder)
1160
1610
  throw new Error("Builder Not Found");
@@ -1170,6 +1620,7 @@ ${this.#backendStderrTail.join(`
1170
1620
  settle(() => reject(new Error("[cli] builder timed out before emitting builder-ready")));
1171
1621
  }, BUILDER_READY_TIMEOUT_MS);
1172
1622
  this.#builder.start({
1623
+ announceBootState,
1173
1624
  onExit: () => {
1174
1625
  settle(() => reject(new Error(`[cli] builder exited before emitting builder-ready (attempt ${attempt})`)));
1175
1626
  },
@@ -1184,6 +1635,12 @@ ${this.#backendStderrTail.join(`
1184
1635
  });
1185
1636
  }
1186
1637
  #sendToBuilder(message) {
1638
+ this.#markDevActivity();
1639
+ if (this.#suspended || this.#waking) {
1640
+ this.#pendingBuilderMessages.push(message);
1641
+ this.#wakeFromIdle(`${message.type} arrived while suspended`);
1642
+ return;
1643
+ }
1187
1644
  if (message.type === "build-csr" && this.env.AKAN_DEV_CSR_REBUILD !== "1") {
1188
1645
  Object.assign(this.env, { AKAN_DEV_CSR_REBUILD: "1" });
1189
1646
  this.logger.verbose(`[csr] armed dev CSR rebuilds (${message.reason})`);
@@ -1212,6 +1669,7 @@ ${this.#backendStderrTail.join(`
1212
1669
  this.logger.warn("akanAppHost builder is not running");
1213
1670
  }
1214
1671
  #stopBuilder() {
1672
+ this.#cancelRssRecycle();
1215
1673
  if (!this.#builder)
1216
1674
  return;
1217
1675
  this.#builder.stop();
@@ -1259,9 +1717,9 @@ function openBrowser(url) {
1259
1717
  }
1260
1718
 
1261
1719
  // pkgs/@akanjs/cli/application/application.runner.ts
1262
- var loadBuildRunner = async () => (await import("./applicationBuildRunner-yz144508.js")).ApplicationBuildRunner;
1720
+ var loadBuildRunner = async () => (await import("./applicationBuildRunner-5w8j9yp7.js")).ApplicationBuildRunner;
1263
1721
  var loadReleasePackager = async () => (await import("./applicationReleasePackager-brvth6rs.js")).ApplicationReleasePackager;
1264
- var loadCapacitorApp = async () => (await import("./capacitorApp-y0h6cgft.js")).CapacitorApp;
1722
+ var loadCapacitorApp = async () => (await import("./capacitorApp-wnxzrppx.js")).CapacitorApp;
1265
1723
  var loadPrompts = async () => await import("@inquirer/prompts");
1266
1724
 
1267
1725
  class ApplicationRunner extends runner("application") {