@akanjs/devkit 2.4.0-rc.8 → 2.4.0-rc.9
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.test.ts +92 -0
- package/akanApp/akanApp.host.ts +161 -13
- package/akanConfig/akanConfig.test.ts +25 -3
- package/akanConfig/akanConfig.ts +19 -1
- package/capacitorApp.test.ts +86 -2
- package/capacitorApp.ts +203 -19
- package/incrementalBuilder/incrementalBuilder.proc.ts +130 -25
- package/package.json +2 -2
|
@@ -4,8 +4,10 @@ import {
|
|
|
4
4
|
backendRestartReasonFromMessage,
|
|
5
5
|
buildStatusReplaySequence,
|
|
6
6
|
createBackendBuildStatus,
|
|
7
|
+
hasBuildFailureForGeneration,
|
|
7
8
|
isLegacyBackendFallbackFile,
|
|
8
9
|
mergeBackendRestartReasons,
|
|
10
|
+
mergeInvalidateMessages,
|
|
9
11
|
normalizeBackendReportedGeneration,
|
|
10
12
|
shouldAbandonBackendRecovery,
|
|
11
13
|
shouldMarkBuildPhaseRecovered,
|
|
@@ -226,6 +228,96 @@ describe("backend recovery abandonment", () => {
|
|
|
226
228
|
});
|
|
227
229
|
});
|
|
228
230
|
|
|
231
|
+
describe("hasBuildFailureForGeneration", () => {
|
|
232
|
+
const status = (phase: DevBuildStatus["phase"], generation: number, ok: boolean): DevBuildStatus => ({
|
|
233
|
+
generation,
|
|
234
|
+
phase,
|
|
235
|
+
ok,
|
|
236
|
+
files: [],
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
test("detects a failing phase recorded for the same generation", () => {
|
|
240
|
+
const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
|
|
241
|
+
["csr", status("csr", 3, false)],
|
|
242
|
+
["barrel", status("barrel", 3, true)],
|
|
243
|
+
]);
|
|
244
|
+
expect(hasBuildFailureForGeneration(statusByPhase, 3)).toBe(true);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
test("ignores stale failures from earlier generations", () => {
|
|
248
|
+
const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([
|
|
249
|
+
["csr", status("csr", 3, false)],
|
|
250
|
+
["barrel", status("barrel", 4, true)],
|
|
251
|
+
]);
|
|
252
|
+
expect(hasBuildFailureForGeneration(statusByPhase, 4)).toBe(false);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("treats unknown generations and green boards as healthy", () => {
|
|
256
|
+
const statusByPhase = new Map<DevBuildStatus["phase"], DevBuildStatus>([["csr", status("csr", 3, false)]]);
|
|
257
|
+
expect(hasBuildFailureForGeneration(statusByPhase, undefined)).toBe(false);
|
|
258
|
+
expect(hasBuildFailureForGeneration(new Map(), 3)).toBe(false);
|
|
259
|
+
});
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
describe("mergeInvalidateMessages", () => {
|
|
263
|
+
const invalidate = (
|
|
264
|
+
generation: number,
|
|
265
|
+
files: string[],
|
|
266
|
+
actions: DevChangeAction[],
|
|
267
|
+
): Extract<BuilderMessage, { type: "invalidate" }> => ({
|
|
268
|
+
type: "invalidate",
|
|
269
|
+
kinds: ["code"],
|
|
270
|
+
files,
|
|
271
|
+
generation,
|
|
272
|
+
devPlan: {
|
|
273
|
+
generation,
|
|
274
|
+
files,
|
|
275
|
+
generatedFiles: [],
|
|
276
|
+
roles: ["shared"],
|
|
277
|
+
actions,
|
|
278
|
+
reasonByFile: { [files[0] ?? "/repo/a.ts"]: ["shared-path"] },
|
|
279
|
+
},
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
test("unions files, kinds, and actions while keeping the latest generation", () => {
|
|
283
|
+
const merged = mergeInvalidateMessages(
|
|
284
|
+
invalidate(3, ["/repo/b.ts", "/repo/a.ts"], ["restart-builder"]),
|
|
285
|
+
invalidate(5, ["/repo/c.ts"], ["rebuild-client"]),
|
|
286
|
+
);
|
|
287
|
+
|
|
288
|
+
expect(merged.generation).toBe(5);
|
|
289
|
+
expect(merged.files).toEqual(["/repo/a.ts", "/repo/b.ts", "/repo/c.ts"]);
|
|
290
|
+
expect(merged.devPlan?.generation).toBe(5);
|
|
291
|
+
expect(merged.devPlan?.actions).toEqual(["rebuild-client", "restart-builder"]);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test("keeps the surviving devPlan when only one side carries one", () => {
|
|
295
|
+
const withPlan = invalidate(3, ["/repo/a.ts"], ["restart-builder"]);
|
|
296
|
+
const withoutPlan: Extract<BuilderMessage, { type: "invalidate" }> = {
|
|
297
|
+
type: "invalidate",
|
|
298
|
+
kinds: ["css"],
|
|
299
|
+
files: ["/repo/style.css"],
|
|
300
|
+
generation: 4,
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const merged = mergeInvalidateMessages(withPlan, withoutPlan);
|
|
304
|
+
expect(merged.devPlan?.actions).toEqual(["restart-builder"]);
|
|
305
|
+
expect(merged.kinds).toEqual(["code", "css"]);
|
|
306
|
+
expect(merged.generation).toBe(4);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
test("merges per-file reasons without duplicating entries", () => {
|
|
310
|
+
const current = invalidate(3, ["/repo/a.ts"], ["restart-builder"]);
|
|
311
|
+
const next = invalidate(4, ["/repo/a.ts"], ["restart-builder"]);
|
|
312
|
+
const nextPlan = next.devPlan;
|
|
313
|
+
if (!nextPlan) throw new Error("devPlan expected");
|
|
314
|
+
nextPlan.reasonByFile["/repo/a.ts"] = ["runtime-metadata", "shared-path"];
|
|
315
|
+
|
|
316
|
+
const merged = mergeInvalidateMessages(current, next);
|
|
317
|
+
expect(merged.devPlan?.reasonByFile["/repo/a.ts"]).toEqual(["runtime-metadata", "shared-path"]);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
229
321
|
describe("normalizeBackendReportedGeneration", () => {
|
|
230
322
|
test("drops the gateway's unknown-generation sentinel", () => {
|
|
231
323
|
expect(normalizeBackendReportedGeneration(-1)).toBeUndefined();
|
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { Logger } from "akanjs/common";
|
|
3
|
-
import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangeRole } from "akanjs/server";
|
|
3
|
+
import type { BuilderMessage, BuildPhase, DevBuildStatus, DevChangePlan, DevChangeRole } from "akanjs/server";
|
|
4
4
|
import type { App } from "../commandDecorators";
|
|
5
5
|
import { createTunnel } from "../createTunnel";
|
|
6
6
|
import { WorkspaceExecutor } from "../executors";
|
|
@@ -17,6 +17,10 @@ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
|
|
|
17
17
|
const BACKEND_STDERR_TAIL_LIMIT = 40;
|
|
18
18
|
const BUILDER_READY_TIMEOUT_MS = 150000;
|
|
19
19
|
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
20
|
+
// The builder is the file watcher: while it is down no edit can trigger a retry, so unlike the
|
|
21
|
+
// backend the recovery loop never gives up — it only backs off.
|
|
22
|
+
const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
|
|
23
|
+
const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
|
|
20
24
|
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
21
25
|
const NON_SOURCE_EXT_RE =
|
|
22
26
|
/\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
|
|
@@ -156,6 +160,55 @@ export const shouldReplaceLastGoodMessage = (
|
|
|
156
160
|
export const shouldQueueBuildStatusReplay = (backendReady: boolean, pendingReplayCount: number): boolean =>
|
|
157
161
|
!backendReady || pendingReplayCount > 0;
|
|
158
162
|
|
|
163
|
+
/**
|
|
164
|
+
* Recycling the builder/backend on a generation whose build already failed is guaranteed to strand
|
|
165
|
+
* the dev server: the rebooted builder hits the same compile error and exits before builder-ready.
|
|
166
|
+
* Failing phase statuses for a generation arrive over IPC before that generation's invalidate, so
|
|
167
|
+
* the host can check them here and defer the recycle until a healthy batch lands.
|
|
168
|
+
*/
|
|
169
|
+
export const hasBuildFailureForGeneration = (
|
|
170
|
+
statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
171
|
+
generation: number | undefined,
|
|
172
|
+
): boolean => {
|
|
173
|
+
if (typeof generation !== "number") return false;
|
|
174
|
+
for (const status of statusByPhase.values()) {
|
|
175
|
+
if (!status.ok && status.generation === generation) return true;
|
|
176
|
+
}
|
|
177
|
+
return false;
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
const mergeDevPlans = (current?: DevChangePlan, next?: DevChangePlan): DevChangePlan | undefined => {
|
|
181
|
+
if (!current) return next;
|
|
182
|
+
if (!next) return current;
|
|
183
|
+
const reasonByFile: Record<string, string[]> = { ...current.reasonByFile };
|
|
184
|
+
for (const [file, reasons] of Object.entries(next.reasonByFile)) {
|
|
185
|
+
reasonByFile[file] = [...new Set([...(reasonByFile[file] ?? []), ...reasons])].sort();
|
|
186
|
+
}
|
|
187
|
+
return {
|
|
188
|
+
generation: Math.max(current.generation, next.generation),
|
|
189
|
+
files: [...new Set([...current.files, ...next.files])].sort(),
|
|
190
|
+
generatedFiles: [...new Set([...current.generatedFiles, ...next.generatedFiles])].sort(),
|
|
191
|
+
roles: [...new Set([...current.roles, ...next.roles])].sort(),
|
|
192
|
+
actions: [...new Set([...current.actions, ...next.actions])].sort(),
|
|
193
|
+
reasonByFile,
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
/** A deferred recycle accumulates every batch it skipped so the eventual restart covers them all. */
|
|
198
|
+
export const mergeInvalidateMessages = (
|
|
199
|
+
current: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
200
|
+
next: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
201
|
+
): Extract<BuilderMessage, { type: "invalidate" }> => {
|
|
202
|
+
const generation = Math.max(generationValue(current.generation), generationValue(next.generation));
|
|
203
|
+
return {
|
|
204
|
+
type: "invalidate",
|
|
205
|
+
kinds: [...new Set([...current.kinds, ...next.kinds])].sort(),
|
|
206
|
+
files: [...new Set([...current.files, ...next.files])].sort(),
|
|
207
|
+
generation: generation >= 0 ? generation : undefined,
|
|
208
|
+
devPlan: mergeDevPlans(current.devPlan, next.devPlan),
|
|
209
|
+
};
|
|
210
|
+
};
|
|
211
|
+
|
|
159
212
|
export const buildStatusReplaySequence = (
|
|
160
213
|
pendingReplay: readonly DevBuildStatus[],
|
|
161
214
|
latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
@@ -281,8 +334,12 @@ export class AkanAppHost {
|
|
|
281
334
|
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
282
335
|
#backendRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
283
336
|
#backendRecoveryAttempts = 0;
|
|
337
|
+
#backendGaveUp = false;
|
|
284
338
|
#backendLifecycleState: BackendLifecycleState = "stopped";
|
|
285
339
|
#pendingRestartReason: BackendRestartReason | null = null;
|
|
340
|
+
#pendingRecycle: { message: Extract<BuilderMessage, { type: "invalidate" }>; refreshConfig: boolean } | null = null;
|
|
341
|
+
#builderRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
342
|
+
#builderRecoveryAttempts = 0;
|
|
286
343
|
#backendStartStatus: { generation?: number; files: string[] } | null = null;
|
|
287
344
|
#backendBuildStatusGeneration = 0;
|
|
288
345
|
#backendStderrTail: string[] = [];
|
|
@@ -320,6 +377,10 @@ export class AkanAppHost {
|
|
|
320
377
|
clearTimeout(this.#backendRecoveryTimer);
|
|
321
378
|
this.#backendRecoveryTimer = null;
|
|
322
379
|
}
|
|
380
|
+
if (this.#builderRecoveryTimer) {
|
|
381
|
+
clearTimeout(this.#builderRecoveryTimer);
|
|
382
|
+
this.#builderRecoveryTimer = null;
|
|
383
|
+
}
|
|
323
384
|
await this.#stopBackend();
|
|
324
385
|
this.#stopBuilder();
|
|
325
386
|
return this;
|
|
@@ -335,6 +396,7 @@ export class AkanAppHost {
|
|
|
335
396
|
}
|
|
336
397
|
#startBackend(startStatus: { generation?: number; files: string[] } | null = null) {
|
|
337
398
|
this.#backendStartStatus = startStatus;
|
|
399
|
+
this.#backendGaveUp = false;
|
|
338
400
|
this.#setBackendLifecycleState("starting");
|
|
339
401
|
this.#backendReady = false;
|
|
340
402
|
this.#backendStderrTail = [];
|
|
@@ -531,7 +593,8 @@ export class AkanAppHost {
|
|
|
531
593
|
#scheduleBackendRecovery(reason: string) {
|
|
532
594
|
if (this.#backendRecoveryTimer || this.#backend) return;
|
|
533
595
|
if (shouldAbandonBackendRecovery(this.#backendRecoveryAttempts)) {
|
|
534
|
-
const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for a
|
|
596
|
+
const message = `Backend exited ${this.#backendRecoveryAttempts} times in a row (${reason}); waiting for an edit or a green build to retry.`;
|
|
597
|
+
this.#backendGaveUp = true;
|
|
535
598
|
this.#setBackendLifecycleState("stopped", `gave up after ${this.#backendRecoveryAttempts} recovery attempts`);
|
|
536
599
|
this.logger.error(`[backend-recovery] ${message}`);
|
|
537
600
|
if (this.#backendStderrTail.length > 0) {
|
|
@@ -576,6 +639,7 @@ export class AkanAppHost {
|
|
|
576
639
|
if (message.type === "build-status") {
|
|
577
640
|
this.#recordBuildStatus(message.data);
|
|
578
641
|
this.#sendOrQueueBuildStatus(message.data);
|
|
642
|
+
this.#reviveBackendAfterGreenBuild(message.data);
|
|
579
643
|
return;
|
|
580
644
|
}
|
|
581
645
|
if (message.type === "pages-updated") this.#recordLastGood(message);
|
|
@@ -590,19 +654,25 @@ export class AkanAppHost {
|
|
|
590
654
|
this.#logDevPlan(message);
|
|
591
655
|
// Config changes subsume builder restarts: the dev-host restart recycles builder and backend
|
|
592
656
|
// AND re-runs the prepare step, so check it first when a batch carries both actions.
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
657
|
+
const wantsDevHostRestart = shouldRestartDevHostByDevPlan(message);
|
|
658
|
+
const pending = this.#pendingRecycle;
|
|
659
|
+
// A pending (deferred) recycle rides along on the next code batch — that batch is where the
|
|
660
|
+
// fix lands; css-only batches cannot heal a compile error, so they never resume it.
|
|
661
|
+
if (wantsDevHostRestart || shouldRestartBuilderByDevPlan(message) || (pending && message.kinds.includes("code"))) {
|
|
662
|
+
const refreshConfig = wantsDevHostRestart || (pending?.refreshConfig ?? false);
|
|
663
|
+
const merged = pending ? mergeInvalidateMessages(pending.message, message) : message;
|
|
664
|
+
const generation = message.devPlan?.generation ?? message.generation;
|
|
665
|
+
if (hasBuildFailureForGeneration(this.#buildStatusByPhase, generation)) {
|
|
666
|
+
this.#deferRecycle(merged, { refreshConfig, generation });
|
|
667
|
+
return;
|
|
598
668
|
}
|
|
599
|
-
|
|
600
|
-
}
|
|
601
|
-
if (shouldRestartBuilderByDevPlan(message)) {
|
|
669
|
+
this.#pendingRecycle = null;
|
|
602
670
|
try {
|
|
603
|
-
await this.#
|
|
671
|
+
if (refreshConfig) await this.#restartDevHost(merged);
|
|
672
|
+
else await this.#restartDevChildren(merged);
|
|
604
673
|
} catch (err) {
|
|
605
|
-
this.#recordDevHostRestartFailure(
|
|
674
|
+
this.#recordDevHostRestartFailure(merged, err, refreshConfig ? "Config" : "Runtime metadata");
|
|
675
|
+
this.#resurrectDevChildren(merged);
|
|
606
676
|
}
|
|
607
677
|
return;
|
|
608
678
|
}
|
|
@@ -612,6 +682,79 @@ export class AkanAppHost {
|
|
|
612
682
|
}
|
|
613
683
|
this.#sendToBackend(message);
|
|
614
684
|
}
|
|
685
|
+
#deferRecycle(
|
|
686
|
+
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
687
|
+
{ refreshConfig, generation }: { refreshConfig: boolean; generation?: number },
|
|
688
|
+
): void {
|
|
689
|
+
this.#pendingRecycle = { message, refreshConfig };
|
|
690
|
+
const kind = refreshConfig ? "Config" : "Runtime metadata";
|
|
691
|
+
this.logger.warn(
|
|
692
|
+
`[dev-host] ${kind.toLowerCase()} restart deferred generation=${generation ?? "(unknown)"}; keeping the running dev server until the build error is fixed`,
|
|
693
|
+
);
|
|
694
|
+
const status: DevBuildStatus = {
|
|
695
|
+
generation: generation ?? this.#nextBackendBuildStatusGeneration(),
|
|
696
|
+
phase: "scan",
|
|
697
|
+
ok: false,
|
|
698
|
+
files: message.files,
|
|
699
|
+
message: `${kind} change is on hold while the build is failing; it will apply automatically once the error is fixed.`,
|
|
700
|
+
};
|
|
701
|
+
this.#recordBuildStatus(status);
|
|
702
|
+
this.#sendOrQueueBuildStatus(status);
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* A failed recycle must never leave the dev server dead: bring the backend back up on the
|
|
706
|
+
* last-good artifact so the error overlay stays reachable, and keep retrying the builder —
|
|
707
|
+
* the builder is the file watcher, so without it no edit could ever trigger a recovery.
|
|
708
|
+
*/
|
|
709
|
+
#resurrectDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): void {
|
|
710
|
+
const generation = message.devPlan?.generation ?? message.generation;
|
|
711
|
+
if (!this.#backend) this.#startBackend({ generation, files: message.files });
|
|
712
|
+
this.#scheduleBuilderRecovery({ generation, files: message.files });
|
|
713
|
+
}
|
|
714
|
+
#scheduleBuilderRecovery(reason: { generation?: number; files: string[] }): void {
|
|
715
|
+
if (this.#builderRecoveryTimer || this.#builder) return;
|
|
716
|
+
const attempt = this.#builderRecoveryAttempts;
|
|
717
|
+
const delay = Math.min(BUILDER_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BUILDER_RECOVERY_MAX_DELAY_MS);
|
|
718
|
+
this.#builderRecoveryAttempts = attempt + 1;
|
|
719
|
+
this.logger.warn(
|
|
720
|
+
`[builder-recovery] builder is down; retrying start in ${delay}ms (attempt ${this.#builderRecoveryAttempts})`,
|
|
721
|
+
);
|
|
722
|
+
this.#builderRecoveryTimer = setTimeout(() => {
|
|
723
|
+
this.#builderRecoveryTimer = null;
|
|
724
|
+
if (this.#builder) return;
|
|
725
|
+
void this.#recoverBuilder(reason);
|
|
726
|
+
}, delay);
|
|
727
|
+
}
|
|
728
|
+
async #recoverBuilder(reason: { generation?: number; files: string[] }): Promise<void> {
|
|
729
|
+
try {
|
|
730
|
+
await this.#startBuilder();
|
|
731
|
+
} catch (err) {
|
|
732
|
+
this.logger.warn(`[builder-recovery] builder start failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
733
|
+
this.#scheduleBuilderRecovery(reason);
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
this.#builderRecoveryAttempts = 0;
|
|
737
|
+
this.logger.info("[builder-recovery] builder recovered");
|
|
738
|
+
const status: DevBuildStatus = {
|
|
739
|
+
generation: reason.generation ?? this.#nextBackendBuildStatusGeneration(),
|
|
740
|
+
phase: "scan",
|
|
741
|
+
ok: true,
|
|
742
|
+
files: reason.files,
|
|
743
|
+
message: "Builder recovered",
|
|
744
|
+
};
|
|
745
|
+
this.#recordBuildStatus(status);
|
|
746
|
+
this.#sendOrQueueBuildStatus(status);
|
|
747
|
+
if (!this.#backend && !this.#backendRecoveryTimer) {
|
|
748
|
+
this.#startBackend({ generation: reason.generation, files: reason.files });
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/** A backend that gave up recovering on broken code gets one fresh chance whenever a build goes green. */
|
|
752
|
+
#reviveBackendAfterGreenBuild(status: DevBuildStatus): void {
|
|
753
|
+
if (!status.ok || !this.#backendGaveUp || this.#backend || this.#backendRecoveryTimer) return;
|
|
754
|
+
this.logger.info(`[backend-recovery] build went green (generation=${status.generation}); retrying backend`);
|
|
755
|
+
this.#backendRecoveryAttempts = 0;
|
|
756
|
+
this.#startBackend({ generation: status.generation, files: status.files });
|
|
757
|
+
}
|
|
615
758
|
async #restartDevChildren(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<void> {
|
|
616
759
|
const generation = message.devPlan?.generation ?? message.generation;
|
|
617
760
|
this.logger.warn(
|
|
@@ -646,6 +789,11 @@ export class AkanAppHost {
|
|
|
646
789
|
clearTimeout(this.#backendRecoveryTimer);
|
|
647
790
|
this.#backendRecoveryTimer = null;
|
|
648
791
|
}
|
|
792
|
+
if (this.#builderRecoveryTimer) {
|
|
793
|
+
clearTimeout(this.#builderRecoveryTimer);
|
|
794
|
+
this.#builderRecoveryTimer = null;
|
|
795
|
+
}
|
|
796
|
+
this.#builderRecoveryAttempts = 0;
|
|
649
797
|
this.#pendingRestartReason = null;
|
|
650
798
|
this.#lastGoodFrontend = {};
|
|
651
799
|
this.#buildStatusByPhase.clear();
|
|
@@ -693,7 +841,7 @@ export class AkanAppHost {
|
|
|
693
841
|
phase: "scan",
|
|
694
842
|
ok: false,
|
|
695
843
|
files: message.files,
|
|
696
|
-
message: `${kind} change
|
|
844
|
+
message: `${kind} change failed to apply; recovering the dev server automatically: ${detail}`,
|
|
697
845
|
};
|
|
698
846
|
this.#recordBuildStatus(status);
|
|
699
847
|
this.#sendOrQueueBuildStatus(status);
|
|
@@ -4,7 +4,7 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import type { PackageJson } from "../types";
|
|
7
|
-
import { AkanAppConfig, AkanLibConfig } from "./akanConfig";
|
|
7
|
+
import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
|
|
8
8
|
import type { DeepPartial, LibConfigResult } from "./types";
|
|
9
9
|
|
|
10
10
|
const akanPackageJson = JSON.parse(
|
|
@@ -48,14 +48,14 @@ describe("AkanAppConfig", () => {
|
|
|
48
48
|
expect(config.images.formats).toEqual(["image/webp"]);
|
|
49
49
|
expect(config.mobile).toMatchObject({
|
|
50
50
|
appName: "portal",
|
|
51
|
-
appId: "com.portal
|
|
51
|
+
appId: "com.akanjs.portal",
|
|
52
52
|
version: "0.0.1",
|
|
53
53
|
buildNum: 1,
|
|
54
54
|
targets: {
|
|
55
55
|
default: {
|
|
56
56
|
name: "default",
|
|
57
57
|
appName: "portal",
|
|
58
|
-
appId: "com.portal
|
|
58
|
+
appId: "com.akanjs.portal",
|
|
59
59
|
version: "0.0.1",
|
|
60
60
|
buildNum: 1,
|
|
61
61
|
},
|
|
@@ -368,6 +368,28 @@ describe("AkanAppConfig", () => {
|
|
|
368
368
|
),
|
|
369
369
|
).toThrow("unknown basePath");
|
|
370
370
|
});
|
|
371
|
+
|
|
372
|
+
test("derives a repo-scoped default appId and records explicit-mobile intent", () => {
|
|
373
|
+
// No mobile section: appId defaults to the repo-scoped reverse-DNS id, and mobile is not explicit.
|
|
374
|
+
const withoutMobile = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
|
|
375
|
+
expect(withoutMobile.mobile.appId).toBe("com.akanjs.portal");
|
|
376
|
+
expect(withoutMobile.hasMobileConfig).toBe(false);
|
|
377
|
+
|
|
378
|
+
// Explicit mobile section without appId still uses the repo-scoped default but marks intent.
|
|
379
|
+
const withMobile = new AkanAppConfig(app, [], packageJson, { mobile: { version: "2.0.0" } }, baseDevEnv);
|
|
380
|
+
expect(withMobile.mobile.appId).toBe("com.akanjs.portal");
|
|
381
|
+
expect(withMobile.hasMobileConfig).toBe(true);
|
|
382
|
+
});
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
describe("deriveDefaultAppId", () => {
|
|
386
|
+
test("sanitizes org/app names into a valid reverse-DNS bundle id", () => {
|
|
387
|
+
expect(deriveDefaultAppId("leading-flight-guidance", "myapp")).toBe("com.leadingflightguidance.myapp");
|
|
388
|
+
expect(deriveDefaultAppId("Acme Corp", "Store")).toBe("com.acmecorp.store");
|
|
389
|
+
// Empty org and digit-leading segments stay valid package identifiers.
|
|
390
|
+
expect(deriveDefaultAppId("", "app")).toBe("com.app.app");
|
|
391
|
+
expect(deriveDefaultAppId("123repo", "9app")).toBe("com.app123repo.app9app");
|
|
392
|
+
});
|
|
371
393
|
});
|
|
372
394
|
|
|
373
395
|
describe("AkanLibConfig", () => {
|
package/akanConfig/akanConfig.ts
CHANGED
|
@@ -123,6 +123,21 @@ const normalizeStringList = (values: string[] | undefined) => {
|
|
|
123
123
|
return normalized.length > 0 ? [...new Set(normalized)] : undefined;
|
|
124
124
|
};
|
|
125
125
|
|
|
126
|
+
// Reduce a free-form name to a valid reverse-DNS / Android package segment: lowercase, alphanumerics
|
|
127
|
+
// only (hyphens/spaces dropped), never empty, never starting with a digit.
|
|
128
|
+
const sanitizeAppIdSegment = (value: string): string => {
|
|
129
|
+
const cleaned = value.toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
130
|
+
if (!cleaned) return "app";
|
|
131
|
+
return /^[a-z]/.test(cleaned) ? cleaned : `app${cleaned}`;
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// The default mobile bundle id for an app that has not pinned `mobile.appId`. Uses the workspace
|
|
135
|
+
// (repo) name as the org segment so the default is far less collision-prone than a bare
|
|
136
|
+
// `com.<appName>.app`, which Apple's portal routinely already has claimed. Apps that ship pin an
|
|
137
|
+
// explicit appId, so this only affects unconfigured/dev apps.
|
|
138
|
+
export const deriveDefaultAppId = (orgName: string, appName: string): string =>
|
|
139
|
+
`com.${sanitizeAppIdSegment(orgName)}.${sanitizeAppIdSegment(appName)}`;
|
|
140
|
+
|
|
126
141
|
const normalizeDeepLinkDomain = (domain: string) => {
|
|
127
142
|
const normalized = domain.trim();
|
|
128
143
|
if (!normalized) return "";
|
|
@@ -164,6 +179,8 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
164
179
|
i18n: AkanI18nConfig;
|
|
165
180
|
publicEnv: string[];
|
|
166
181
|
mobile: AkanMobileConfig;
|
|
182
|
+
/** True only when the app's akan.config.ts explicitly declares a `mobile` section (vs. the synthesized default). */
|
|
183
|
+
hasMobileConfig: boolean;
|
|
167
184
|
secrets: string[];
|
|
168
185
|
baseDevEnv: BaseDevEnv;
|
|
169
186
|
libs: string[];
|
|
@@ -202,6 +219,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
202
219
|
process.env.AKAN_PUBLIC_LOCALES = this.i18n.locales.join(",");
|
|
203
220
|
this.publicEnv = (config?.publicEnv as string[] | undefined) ?? ([] as string[]);
|
|
204
221
|
this.secrets = (config?.secrets as string[] | undefined) ?? ([] as string[]);
|
|
222
|
+
this.hasMobileConfig = Boolean(config.mobile);
|
|
205
223
|
this.mobile = this.#resolveMobileConfig(config.mobile);
|
|
206
224
|
this.docker = this.#makeDockerContent(config?.docker ?? {});
|
|
207
225
|
}
|
|
@@ -212,7 +230,7 @@ export class AkanAppConfig implements AppConfigResult {
|
|
|
212
230
|
...rawMobile
|
|
213
231
|
} = (mobile ?? {}) as DeepPartial<AkanMobileConfig> & { indexPath?: unknown };
|
|
214
232
|
const appName = rawMobile.appName ?? this.app.name;
|
|
215
|
-
const appId = rawMobile.appId ??
|
|
233
|
+
const appId = rawMobile.appId ?? deriveDefaultAppId(this.baseDevEnv.repoName, this.app.name);
|
|
216
234
|
const version = rawMobile.version ?? "0.0.1";
|
|
217
235
|
const buildNum = rawMobile.buildNum ?? 1;
|
|
218
236
|
const defaultTargetName = this.#defaultMobileTargetName(rawTargets);
|
package/capacitorApp.test.ts
CHANGED
|
@@ -12,12 +12,17 @@ import {
|
|
|
12
12
|
formatAndroidReleaseSigningError,
|
|
13
13
|
getAdbDeviceStateIssues,
|
|
14
14
|
getMissingAndroidReleaseSigningKeys,
|
|
15
|
+
isPlaceholderAppId,
|
|
15
16
|
materializeCapacitorConfig,
|
|
16
17
|
parseDevicectlDevices,
|
|
18
|
+
parseIosRuntimeMajor,
|
|
17
19
|
parseSimctlDevices,
|
|
18
20
|
raiseGradleMinSdkVersion,
|
|
19
21
|
rootCapacitorConfigFilenames,
|
|
22
|
+
SWIFTUICORE_MIN_IOS_MAJOR,
|
|
20
23
|
sanitizeIosNativeRunEnv,
|
|
24
|
+
selectLocalDevHost,
|
|
25
|
+
sortIosRunTargets,
|
|
21
26
|
writeRootCapacitorConfig,
|
|
22
27
|
} from "./capacitorApp";
|
|
23
28
|
|
|
@@ -182,17 +187,42 @@ describe("iOS native run helpers", () => {
|
|
|
182
187
|
parseSimctlDevices(
|
|
183
188
|
JSON.stringify({
|
|
184
189
|
devices: {
|
|
185
|
-
"iOS
|
|
190
|
+
"com.apple.CoreSimulator.SimRuntime.iOS-18-2": [
|
|
186
191
|
{ udid: "11111111-2222-3333-4444-555555555555", name: "iPhone 16", state: "Shutdown", isAvailable: true },
|
|
187
192
|
],
|
|
188
193
|
},
|
|
189
194
|
}),
|
|
190
195
|
),
|
|
191
196
|
).toEqual([
|
|
192
|
-
{
|
|
197
|
+
{
|
|
198
|
+
id: "11111111-2222-3333-4444-555555555555",
|
|
199
|
+
name: "iPhone 16",
|
|
200
|
+
kind: "simulator",
|
|
201
|
+
state: "Shutdown",
|
|
202
|
+
runtime: "iOS 18.2",
|
|
203
|
+
},
|
|
193
204
|
]);
|
|
194
205
|
});
|
|
195
206
|
|
|
207
|
+
test("sorts run targets so booted / connected / newest-runtime targets come first", () => {
|
|
208
|
+
const ios17 = { id: "a", name: "iPhone 15", kind: "simulator" as const, state: "Shutdown", runtime: "iOS 17.5" };
|
|
209
|
+
const ios18 = { id: "b", name: "iPhone 16", kind: "simulator" as const, state: "Shutdown", runtime: "iOS 18.2" };
|
|
210
|
+
const booted17 = { id: "c", name: "iPhone SE", kind: "simulator" as const, state: "Booted", runtime: "iOS 17.0" };
|
|
211
|
+
const device = { id: "d", name: "Seok iPhone", kind: "device" as const, state: "available wired paired" };
|
|
212
|
+
// Input is in the old ascending-runtime order that used to default to iOS 17.
|
|
213
|
+
const sorted = sortIosRunTargets([ios17, ios18, booted17, device]);
|
|
214
|
+
expect(sorted.map((t) => t.id)).toEqual(["d", "c", "b", "a"]);
|
|
215
|
+
// Newest available simulator beats the older one, so the interactive default is no longer iOS 17.
|
|
216
|
+
expect(sortIosRunTargets([ios17, ios18]).map((t) => t.id)).toEqual(["b", "a"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("parses the major iOS runtime version", () => {
|
|
220
|
+
expect(parseIosRuntimeMajor("iOS 18.2")).toBe(18);
|
|
221
|
+
expect(parseIosRuntimeMajor("iOS 17.5")).toBe(17);
|
|
222
|
+
expect(parseIosRuntimeMajor(undefined)).toBeUndefined();
|
|
223
|
+
expect(SWIFTUICORE_MIN_IOS_MAJOR).toBe(18);
|
|
224
|
+
});
|
|
225
|
+
|
|
196
226
|
test("builds xcodebuild destinations and output paths by target kind", () => {
|
|
197
227
|
const deviceCommand = buildIosNativeRunCommand({
|
|
198
228
|
appRoot: "/repo/apps/minimal",
|
|
@@ -228,6 +258,11 @@ describe("iOS native run helpers", () => {
|
|
|
228
258
|
expect(classifyIosRunFailure("arm64-apple-darwin20.0: error: unknown argument: '-index-store-path'").kind).toBe(
|
|
229
259
|
"compiler-toolchain",
|
|
230
260
|
);
|
|
261
|
+
expect(
|
|
262
|
+
classifyIosRunFailure(
|
|
263
|
+
"dyld[1234]: Library not loaded: /System/Library/Frameworks/SwiftUICore.framework/SwiftUICore",
|
|
264
|
+
).kind,
|
|
265
|
+
).toBe("simulator-runtime");
|
|
231
266
|
});
|
|
232
267
|
|
|
233
268
|
test("removes shell compiler variables from iOS native run env", () => {
|
|
@@ -240,6 +275,55 @@ describe("iOS native run helpers", () => {
|
|
|
240
275
|
}),
|
|
241
276
|
).toEqual({ AKAN_PUBLIC_APP_NAME: "minimal" });
|
|
242
277
|
});
|
|
278
|
+
|
|
279
|
+
test("flags placeholder bundle identifiers that Apple's portal already claims", () => {
|
|
280
|
+
for (const appId of ["com.myapp.app", "com.myorg.myapp", "com.example.foo", "com.changeme.app", ""]) {
|
|
281
|
+
expect(isPlaceholderAppId(appId)).toBe(true);
|
|
282
|
+
}
|
|
283
|
+
for (const appId of ["com.minimal.app", "com.nearthlab.leadingflight", "io.akanjs.demo"]) {
|
|
284
|
+
expect(isPlaceholderAppId(appId)).toBe(false);
|
|
285
|
+
}
|
|
286
|
+
});
|
|
287
|
+
});
|
|
288
|
+
|
|
289
|
+
describe("selectLocalDevHost", () => {
|
|
290
|
+
const build = (map: Record<string, { family: string; address: string; internal?: boolean }[]>) =>
|
|
291
|
+
map as unknown as Parameters<typeof selectLocalDevHost>[0];
|
|
292
|
+
|
|
293
|
+
test("prefers a routable LAN NIC over an inactive bridge enumerated first", () => {
|
|
294
|
+
const resolution = selectLocalDevHost(
|
|
295
|
+
build({
|
|
296
|
+
bridge0: [{ family: "IPv4", address: "192.168.2.1" }],
|
|
297
|
+
en0: [{ family: "IPv4", address: "172.25.140.140" }],
|
|
298
|
+
bridge100: [{ family: "IPv4", address: "192.168.3.1" }],
|
|
299
|
+
en8: [{ family: "IPv4", address: "172.25.40.227" }],
|
|
300
|
+
}),
|
|
301
|
+
);
|
|
302
|
+
expect(resolution.host).toBe("172.25.140.140");
|
|
303
|
+
expect(resolution.source).toBe("detected");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test("an explicit override always wins and is trimmed", () => {
|
|
307
|
+
const resolution = selectLocalDevHost(build({ en0: [{ family: "IPv4", address: "172.25.140.140" }] }), {
|
|
308
|
+
override: " 10.0.0.9 ",
|
|
309
|
+
});
|
|
310
|
+
expect(resolution).toMatchObject({ host: "10.0.0.9", source: "override" });
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
test("never selects a link-local address when a real LAN address exists", () => {
|
|
314
|
+
const resolution = selectLocalDevHost(
|
|
315
|
+
build({
|
|
316
|
+
en0: [{ family: "IPv4", address: "169.254.10.10" }],
|
|
317
|
+
en1: [{ family: "IPv4", address: "192.168.1.5" }],
|
|
318
|
+
}),
|
|
319
|
+
);
|
|
320
|
+
expect(resolution.host).toBe("192.168.1.5");
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test("falls back to loopback when nothing routable is present", () => {
|
|
324
|
+
const resolution = selectLocalDevHost(build({ lo0: [{ family: "IPv4", address: "127.0.0.1", internal: true }] }));
|
|
325
|
+
expect(resolution).toMatchObject({ host: "127.0.0.1", source: "loopback" });
|
|
326
|
+
});
|
|
243
327
|
});
|
|
244
328
|
|
|
245
329
|
describe("Android signing diagnostics", () => {
|
package/capacitorApp.ts
CHANGED
|
@@ -34,6 +34,8 @@ export interface IosRunTarget {
|
|
|
34
34
|
name: string;
|
|
35
35
|
kind: IosRunTargetKind;
|
|
36
36
|
state?: string;
|
|
37
|
+
/** Display runtime for simulators, e.g. "iOS 18.2". Undefined for physical devices. */
|
|
38
|
+
runtime?: string;
|
|
37
39
|
devicectlId?: string;
|
|
38
40
|
xcodebuildId?: string;
|
|
39
41
|
}
|
|
@@ -56,8 +58,13 @@ export type IosRunFailureKind =
|
|
|
56
58
|
| "device-registration"
|
|
57
59
|
| "device-state"
|
|
58
60
|
| "devicectl-unavailable"
|
|
61
|
+
| "simulator-runtime"
|
|
59
62
|
| "unknown";
|
|
60
63
|
|
|
64
|
+
// Recent iOS SDKs (iOS 18+) split SwiftUI into a SwiftUICore dylib that does not exist on older
|
|
65
|
+
// runtimes, so an app built against them dyld-crashes at launch on an iOS <18 simulator/device.
|
|
66
|
+
export const SWIFTUICORE_MIN_IOS_MAJOR = 18;
|
|
67
|
+
|
|
61
68
|
export interface IosRunFailureClassification {
|
|
62
69
|
kind: IosRunFailureKind;
|
|
63
70
|
title: string;
|
|
@@ -113,15 +120,73 @@ interface MaterializeCapacitorConfigOptions {
|
|
|
113
120
|
localIp?: string;
|
|
114
121
|
}
|
|
115
122
|
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
123
|
+
export interface LocalDevHostResolution {
|
|
124
|
+
host: string;
|
|
125
|
+
source: "override" | "detected" | "loopback";
|
|
126
|
+
candidates: { name: string; address: string }[];
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Interface-name prefixes that are almost never the routable LAN NIC a physical device can reach:
|
|
130
|
+
// bridges (Thunderbolt/USB), tunnels, AirDrop/awrl, VM/container virtual adapters.
|
|
131
|
+
const virtualInterfacePrefixes = [
|
|
132
|
+
"bridge",
|
|
133
|
+
"utun",
|
|
134
|
+
"llw",
|
|
135
|
+
"awdl",
|
|
136
|
+
"ap",
|
|
137
|
+
"vmnet",
|
|
138
|
+
"vnic",
|
|
139
|
+
"tap",
|
|
140
|
+
"tun",
|
|
141
|
+
"docker",
|
|
142
|
+
"veth",
|
|
143
|
+
"vboxnet",
|
|
144
|
+
"gif",
|
|
145
|
+
"stf",
|
|
146
|
+
];
|
|
147
|
+
const physicalInterfacePrefixes = ["en", "eth", "wlan", "wlp", "enp", "eno", "wlo"];
|
|
148
|
+
|
|
149
|
+
const isPrivateLanIpv4 = (address: string): boolean => {
|
|
150
|
+
if (address.startsWith("10.") || address.startsWith("192.168.")) return true;
|
|
151
|
+
const secondOctet = Number(address.match(/^172\.(\d+)\./)?.[1]);
|
|
152
|
+
return Number.isFinite(secondOctet) && secondOctet >= 16 && secondOctet <= 31;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
// Higher = more likely to be the reachable LAN address. Link-local (169.254) is never routable;
|
|
156
|
+
// virtual/bridge interfaces are demoted below real NICs; private-LAN ranges get a small boost.
|
|
157
|
+
const scoreDevHostCandidate = (name: string, address: string): number => {
|
|
158
|
+
const lowerName = name.toLowerCase();
|
|
159
|
+
let score = 0;
|
|
160
|
+
if (address.startsWith("169.254.")) score -= 1000;
|
|
161
|
+
if (virtualInterfacePrefixes.some((prefix) => lowerName.startsWith(prefix))) score -= 100;
|
|
162
|
+
else if (physicalInterfacePrefixes.some((prefix) => lowerName.startsWith(prefix))) score += 100;
|
|
163
|
+
if (isPrivateLanIpv4(address)) score += 10;
|
|
164
|
+
return score;
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
// Pick the dev-server host a physical device should connect to. An explicit override always wins;
|
|
168
|
+
// otherwise rank non-internal IPv4 interfaces so a down/virtual interface (e.g. an inactive
|
|
169
|
+
// Thunderbolt bridge enumerated first) never shadows a real LAN NIC. Deterministic tie-break.
|
|
170
|
+
export const selectLocalDevHost = (
|
|
171
|
+
interfaces: NodeJS.Dict<os.NetworkInterfaceInfo[]>,
|
|
172
|
+
{ override }: { override?: string } = {},
|
|
173
|
+
): LocalDevHostResolution => {
|
|
174
|
+
const candidates: { name: string; address: string }[] = [];
|
|
175
|
+
for (const [name, aliases] of Object.entries(interfaces)) {
|
|
176
|
+
for (const alias of aliases ?? []) {
|
|
177
|
+
if (alias.family !== "IPv4" || alias.internal) continue;
|
|
178
|
+
candidates.push({ name, address: alias.address });
|
|
122
179
|
}
|
|
123
180
|
}
|
|
124
|
-
|
|
181
|
+
const trimmedOverride = override?.trim();
|
|
182
|
+
if (trimmedOverride) return { host: trimmedOverride, source: "override", candidates };
|
|
183
|
+
const [best] = [...candidates].sort(
|
|
184
|
+
(a, b) =>
|
|
185
|
+
scoreDevHostCandidate(b.name, b.address) - scoreDevHostCandidate(a.name, a.address) ||
|
|
186
|
+
a.name.localeCompare(b.name) ||
|
|
187
|
+
a.address.localeCompare(b.address),
|
|
188
|
+
);
|
|
189
|
+
return best ? { host: best.address, source: "detected", candidates } : { host: "127.0.0.1", source: "loopback", candidates };
|
|
125
190
|
};
|
|
126
191
|
|
|
127
192
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
@@ -147,6 +212,38 @@ const dedupeIosRunTargets = (targets: IosRunTarget[]) => {
|
|
|
147
212
|
return [...byKey.values()];
|
|
148
213
|
};
|
|
149
214
|
|
|
215
|
+
// Normalize a simctl runtime — either the JSON key ("com.apple.CoreSimulator.SimRuntime.iOS-18-2")
|
|
216
|
+
// or a per-device `runtimeIdentifier` / text header ("iOS 18.2") — into a "iOS 18.2" display string.
|
|
217
|
+
const formatSimctlRuntime = (key?: string): string | undefined => {
|
|
218
|
+
if (!key) return undefined;
|
|
219
|
+
if (/^(iOS|watchOS|tvOS|visionOS)\s+[\d.]+$/i.test(key)) return key;
|
|
220
|
+
const match = key.match(/(iOS|watchOS|tvOS|visionOS)-(\d+)(?:-(\d+))?/i);
|
|
221
|
+
if (!match) return undefined;
|
|
222
|
+
return `${match[1]} ${match[2]}${match[3] ? `.${match[3]}` : ""}`;
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
// Extract the major OS version from a runtime display string ("iOS 18.2" → 18). Undefined for
|
|
226
|
+
// physical devices (no runtime) or unparseable values.
|
|
227
|
+
export const parseIosRuntimeMajor = (runtime?: string): number | undefined => {
|
|
228
|
+
const major = runtime?.match(/(\d+)/)?.[1];
|
|
229
|
+
if (major === undefined) return undefined;
|
|
230
|
+
const parsed = Number.parseInt(major, 10);
|
|
231
|
+
return Number.isNaN(parsed) ? undefined : parsed;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
// Rank for default-target ordering: ready targets (booted simulator / connected device) first, then
|
|
235
|
+
// physical devices, then newer simulator runtimes. Fixes the old ascending-runtime order that made a
|
|
236
|
+
// stale iOS 17.x simulator the default pick.
|
|
237
|
+
const iosRunTargetRank = (target: IosRunTarget): number => {
|
|
238
|
+
const state = target.state?.toLowerCase() ?? "";
|
|
239
|
+
const ready = state.includes("booted") || state.includes("connected") || state.includes("available") ? 1000 : 0;
|
|
240
|
+
const device = target.kind === "device" ? 500 : 0;
|
|
241
|
+
return ready + device + (parseIosRuntimeMajor(target.runtime) ?? 0);
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
export const sortIosRunTargets = (targets: IosRunTarget[]): IosRunTarget[] =>
|
|
245
|
+
[...targets].sort((a, b) => iosRunTargetRank(b) - iosRunTargetRank(a) || a.name.localeCompare(b.name));
|
|
246
|
+
|
|
150
247
|
function walkRecords(value: unknown, visit: (record: Record<string, unknown>) => void) {
|
|
151
248
|
if (Array.isArray(value)) {
|
|
152
249
|
for (const item of value) walkRecords(item, visit);
|
|
@@ -202,22 +299,36 @@ export function parseSimctlDevices(output: string): IosRunTarget[] {
|
|
|
202
299
|
try {
|
|
203
300
|
const json = JSON.parse(output) as { devices?: Record<string, unknown[]> };
|
|
204
301
|
const devices = json.devices ?? {};
|
|
205
|
-
return Object.
|
|
206
|
-
|
|
207
|
-
.filter(isRecord)
|
|
208
|
-
.flatMap((device) => {
|
|
302
|
+
return Object.entries(devices).flatMap(([runtimeKey, list]) => {
|
|
303
|
+
const runtime = formatSimctlRuntime(runtimeKey);
|
|
304
|
+
return (Array.isArray(list) ? list : []).filter(isRecord).flatMap((device) => {
|
|
209
305
|
const id = firstString(device.udid, device.UDID, device.identifier);
|
|
210
306
|
const name = firstString(device.name, device.displayName);
|
|
211
307
|
const isAvailable = device.isAvailable !== false && device.availabilityError === undefined;
|
|
212
308
|
if (!id || !name || !isAvailable) return [];
|
|
213
|
-
return [
|
|
309
|
+
return [
|
|
310
|
+
{
|
|
311
|
+
id,
|
|
312
|
+
name,
|
|
313
|
+
kind: "simulator" as const,
|
|
314
|
+
state: asString(device.state),
|
|
315
|
+
runtime: runtime ?? formatSimctlRuntime(asString(device.runtimeIdentifier)),
|
|
316
|
+
},
|
|
317
|
+
];
|
|
214
318
|
});
|
|
319
|
+
});
|
|
215
320
|
} catch {
|
|
216
321
|
const targets: IosRunTarget[] = [];
|
|
322
|
+
let runtime: string | undefined;
|
|
217
323
|
for (const line of output.split(/\r?\n/)) {
|
|
324
|
+
const header = line.match(/^--\s*(.+?)\s*--\s*$/);
|
|
325
|
+
if (header) {
|
|
326
|
+
runtime = formatSimctlRuntime(header[1]);
|
|
327
|
+
continue;
|
|
328
|
+
}
|
|
218
329
|
const match = line.match(/^\s*(.+?)\s+\(([0-9A-Fa-f-]{20,})\)\s+\(([^)]+)\)/);
|
|
219
330
|
if (!match) continue;
|
|
220
|
-
targets.push({ id: match[2], name: match[1].trim(), kind: "simulator", state: match[3] });
|
|
331
|
+
targets.push({ id: match[2], name: match[1].trim(), kind: "simulator", state: match[3], runtime });
|
|
221
332
|
}
|
|
222
333
|
return targets;
|
|
223
334
|
}
|
|
@@ -260,6 +371,16 @@ export function buildIosNativeRunCommand({
|
|
|
260
371
|
|
|
261
372
|
export function classifyIosRunFailure(log: string): IosRunFailureClassification {
|
|
262
373
|
const lower = log.toLowerCase();
|
|
374
|
+
if (
|
|
375
|
+
lower.includes("swiftuicore") &&
|
|
376
|
+
(lower.includes("library not loaded") || lower.includes("library missing") || lower.includes("dyld"))
|
|
377
|
+
) {
|
|
378
|
+
return {
|
|
379
|
+
kind: "simulator-runtime",
|
|
380
|
+
title: "App crashed loading SwiftUICore — the iOS runtime is too old.",
|
|
381
|
+
detail: `The build links SwiftUICore, which only exists on iOS ${SWIFTUICORE_MIN_IOS_MAJOR}+. Rerun on an iOS ${SWIFTUICORE_MIN_IOS_MAJOR} or newer simulator/device (pass --device to pick one non-interactively).`,
|
|
382
|
+
};
|
|
383
|
+
}
|
|
263
384
|
if (lower.includes("unknown argument: '-index-store-path'") || lower.includes("compiler was not recognized")) {
|
|
264
385
|
return {
|
|
265
386
|
kind: "compiler-toolchain",
|
|
@@ -379,6 +500,24 @@ export function sanitizeIosNativeRunEnv(env: MobileCommandEnv): MobileCommandEnv
|
|
|
379
500
|
return Object.fromEntries(Object.entries(env).filter(([key]) => !iosNativeBlockedEnvKeys.has(key)));
|
|
380
501
|
}
|
|
381
502
|
|
|
503
|
+
// Bundle IDs that ship as scaffold placeholders (or use an obviously generic org segment) and are
|
|
504
|
+
// almost always already claimed on Apple's developer portal, so device signing fails with
|
|
505
|
+
// "cannot be registered to your development team". A unique reverse-DNS id fixes it.
|
|
506
|
+
export const PLACEHOLDER_APP_IDS = [
|
|
507
|
+
"com.myapp.app",
|
|
508
|
+
"com.myorg.myapp",
|
|
509
|
+
"com.example.app",
|
|
510
|
+
"com.example.myapp",
|
|
511
|
+
] as const;
|
|
512
|
+
const placeholderAppIdSegment = /^(example|examples|myorg|myapp|mycompany|myorganization|changeme|todo|sample|test)$/;
|
|
513
|
+
|
|
514
|
+
export const isPlaceholderAppId = (appId: string | null | undefined): boolean => {
|
|
515
|
+
const normalized = appId?.trim().toLowerCase() ?? "";
|
|
516
|
+
if (!normalized) return true;
|
|
517
|
+
if ((PLACEHOLDER_APP_IDS as readonly string[]).includes(normalized)) return true;
|
|
518
|
+
return normalized.split(".").some((segment) => placeholderAppIdSegment.test(segment));
|
|
519
|
+
};
|
|
520
|
+
|
|
382
521
|
const androidReleaseSigningKeys = [
|
|
383
522
|
"MYAPP_RELEASE_STORE_FILE",
|
|
384
523
|
"MYAPP_RELEASE_STORE_PASSWORD",
|
|
@@ -651,22 +790,46 @@ export class CapacitorApp {
|
|
|
651
790
|
}
|
|
652
791
|
|
|
653
792
|
async #selectIosRunTarget(deviceId?: string) {
|
|
654
|
-
const targets = await this.#loadIosRunTargets();
|
|
793
|
+
const targets = sortIosRunTargets(await this.#loadIosRunTargets());
|
|
655
794
|
if (deviceId) {
|
|
656
|
-
const
|
|
657
|
-
|
|
795
|
+
const needle = deviceId.toLowerCase();
|
|
796
|
+
const found =
|
|
797
|
+
targets.find((target) => target.id === deviceId) ??
|
|
798
|
+
targets.find((target) => target.name.toLowerCase() === needle) ??
|
|
799
|
+
targets.find(
|
|
800
|
+
(target) =>
|
|
801
|
+
target.name.toLowerCase().includes(needle) ||
|
|
802
|
+
target.id.toLowerCase().includes(needle) ||
|
|
803
|
+
(target.runtime?.toLowerCase().includes(needle) ?? false),
|
|
804
|
+
);
|
|
805
|
+
if (!found) {
|
|
806
|
+
const available = targets.map((t) => `${t.name}${t.runtime ? ` (${t.runtime})` : ""}`).join(", ") || "none";
|
|
807
|
+
throw new Error(`iOS run target '${deviceId}' was not found. Available: ${available}`);
|
|
808
|
+
}
|
|
809
|
+
this.#warnIfLegacySimulatorRuntime(found);
|
|
658
810
|
return found;
|
|
659
811
|
}
|
|
660
812
|
if (targets.length === 0) {
|
|
661
813
|
throw new Error("No iOS run targets found. Open Simulator or connect an iPhone, then retry.");
|
|
662
814
|
}
|
|
663
|
-
|
|
815
|
+
const selected = await select<IosRunTarget>({
|
|
664
816
|
message: "Select iOS run target",
|
|
665
817
|
choices: targets.map((target) => ({
|
|
666
|
-
name: `[${target.kind}] ${target.name}${target.state ? ` (${target.state})` : ""}`,
|
|
818
|
+
name: `[${target.kind}] ${target.name}${target.runtime ? ` — ${target.runtime}` : ""}${target.state ? ` (${target.state})` : ""}`,
|
|
667
819
|
value: target,
|
|
668
820
|
})),
|
|
669
821
|
});
|
|
822
|
+
this.#warnIfLegacySimulatorRuntime(selected);
|
|
823
|
+
return selected;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
#warnIfLegacySimulatorRuntime(target: IosRunTarget) {
|
|
827
|
+
if (target.kind !== "simulator") return;
|
|
828
|
+
const major = parseIosRuntimeMajor(target.runtime);
|
|
829
|
+
if (major === undefined || major >= SWIFTUICORE_MIN_IOS_MAJOR) return;
|
|
830
|
+
this.app.logger.warn(
|
|
831
|
+
`Selected simulator runs ${target.runtime ?? "an older iOS"}. Recent SDK builds link SwiftUICore and require iOS ${SWIFTUICORE_MIN_IOS_MAJOR}+; if the app crashes at launch with a "Library not loaded: SwiftUICore" dyld error, pick an iOS ${SWIFTUICORE_MIN_IOS_MAJOR}+ simulator instead.`,
|
|
832
|
+
);
|
|
670
833
|
}
|
|
671
834
|
|
|
672
835
|
async #loadIosRunTargets() {
|
|
@@ -937,7 +1100,13 @@ export class CapacitorApp {
|
|
|
937
1100
|
}
|
|
938
1101
|
async #writeCapacitorConfig({ operation }: Pick<RunConfig, "operation">, commandEnv: MobileCommandEnv) {
|
|
939
1102
|
await mkdir(this.targetRoot, { recursive: true });
|
|
940
|
-
|
|
1103
|
+
let localIp: string | undefined;
|
|
1104
|
+
if (operation === "local") {
|
|
1105
|
+
const override = commandEnv.AKAN_PUBLIC_CLIENT_HOST ?? process.env.AKAN_PUBLIC_CLIENT_HOST;
|
|
1106
|
+
const resolution = selectLocalDevHost(os.networkInterfaces(), { override });
|
|
1107
|
+
localIp = resolution.host;
|
|
1108
|
+
this.#logDevHostResolution(resolution, commandEnv);
|
|
1109
|
+
}
|
|
941
1110
|
const config = materializeCapacitorConfig(this.target, {
|
|
942
1111
|
operation,
|
|
943
1112
|
localIp,
|
|
@@ -947,6 +1116,21 @@ export class CapacitorApp {
|
|
|
947
1116
|
await Bun.write(path.join(this.targetRoot, "capacitor.config.json"), content);
|
|
948
1117
|
return content;
|
|
949
1118
|
}
|
|
1119
|
+
// Surface the live-reload URL a physical device must reach, and warn when auto-detection landed on
|
|
1120
|
+
// a likely-unreachable host so a blank WebView is not mistaken for an app bug.
|
|
1121
|
+
#logDevHostResolution(resolution: LocalDevHostResolution, commandEnv: MobileCommandEnv) {
|
|
1122
|
+
this.app.log(`Mobile live-reload server: ${this.#localCsrUrl(resolution.host, commandEnv)}`);
|
|
1123
|
+
if (resolution.source === "override") return;
|
|
1124
|
+
const suspicious = resolution.host === "127.0.0.1" || resolution.host.startsWith("169.254.");
|
|
1125
|
+
const alternatives = resolution.candidates.filter((candidate) => candidate.address !== resolution.host);
|
|
1126
|
+
if (!suspicious && alternatives.length === 0) return;
|
|
1127
|
+
const alternativeText = alternatives.length
|
|
1128
|
+
? ` Other interfaces: ${alternatives.map((candidate) => `${candidate.address} (${candidate.name})`).join(", ")}.`
|
|
1129
|
+
: "";
|
|
1130
|
+
this.app.logger.warn(
|
|
1131
|
+
`A physical device must reach ${resolution.host} on your LAN.${suspicious ? " That address looks non-routable." : ""}${alternativeText} If the device shows a blank screen, pin the right one with AKAN_PUBLIC_CLIENT_HOST=<ip>.`,
|
|
1132
|
+
);
|
|
1133
|
+
}
|
|
950
1134
|
async #prepareTargetAssets() {
|
|
951
1135
|
if (!this.target.assets) return;
|
|
952
1136
|
await mkdir(this.targetAssetRoot, { recursive: true });
|
|
@@ -36,8 +36,14 @@ interface IncrementalBuilderOptions {
|
|
|
36
36
|
cssCompiler: CssCompiler;
|
|
37
37
|
optimizedFonts: Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
38
38
|
discovery: ClientEntryDiscovery;
|
|
39
|
+
initialGeneration?: number;
|
|
39
40
|
}
|
|
40
41
|
|
|
42
|
+
type IncrementalBuilderBootDeps = Pick<
|
|
43
|
+
IncrementalBuilderOptions,
|
|
44
|
+
"artifact" | "cssCompiler" | "optimizedFonts" | "discovery"
|
|
45
|
+
>;
|
|
46
|
+
|
|
41
47
|
class IncrementalBuilder {
|
|
42
48
|
#logger = new Logger("IncrementalBuilder");
|
|
43
49
|
#app: App;
|
|
@@ -62,11 +68,16 @@ class IncrementalBuilder {
|
|
|
62
68
|
this.#cssCompiler = options.cssCompiler;
|
|
63
69
|
this.#optimizedFonts = options.optimizedFonts;
|
|
64
70
|
this.#discovery = options.discovery;
|
|
71
|
+
this.#generation = options.initialGeneration ?? 0;
|
|
65
72
|
this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
66
73
|
this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
67
74
|
this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
68
75
|
}
|
|
69
76
|
|
|
77
|
+
get #artifactDir() {
|
|
78
|
+
return `${this.#app.cwdPath}/.akan/artifact`;
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
|
|
71
82
|
return this.#enqueueWork(`build-route:${msg.routeId}`, async () => this.#handleBuildRoute(msg));
|
|
72
83
|
}
|
|
@@ -255,7 +266,7 @@ class IncrementalBuilder {
|
|
|
255
266
|
});
|
|
256
267
|
}
|
|
257
268
|
async installWatcher() {
|
|
258
|
-
const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`,
|
|
269
|
+
const [appDir, artifactDir] = [`${this.#app.cwdPath}/page`, this.#artifactDir];
|
|
259
270
|
const roots = await new WatchRootResolver(this.#app).resolve();
|
|
260
271
|
const watcher = new HmrWatcher({
|
|
261
272
|
roots,
|
|
@@ -365,48 +376,142 @@ class IncrementalBuilder {
|
|
|
365
376
|
}
|
|
366
377
|
|
|
367
378
|
async boot(): Promise<void> {
|
|
368
|
-
process.on("message", async (msg: BuilderMessage) => {
|
|
369
|
-
if (!msg || typeof msg !== "object") return;
|
|
370
|
-
switch (msg.type) {
|
|
371
|
-
case "build-route": {
|
|
372
|
-
const res = await this.handleBuildRoute(msg);
|
|
373
|
-
process.send?.(res);
|
|
374
|
-
return;
|
|
375
|
-
}
|
|
376
|
-
default:
|
|
377
|
-
return;
|
|
378
|
-
}
|
|
379
|
-
});
|
|
380
|
-
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
381
|
-
// as an orphaned watcher that keeps rebuilding for nobody.
|
|
382
|
-
process.on("disconnect", () => {
|
|
383
|
-
this.#logger.warn("host IPC channel closed; exiting builder");
|
|
384
|
-
process.exit(0);
|
|
385
|
-
});
|
|
386
379
|
if (this.#watch) await this.installWatcher();
|
|
387
380
|
process.send?.({ type: "builder-ready" });
|
|
388
381
|
this.#logger.verbose(`ready (watch=${this.#watch})`);
|
|
389
382
|
}
|
|
390
383
|
|
|
384
|
+
/**
|
|
385
|
+
* After a degraded boot recovers, the backend is still serving the last-good bundle; push a
|
|
386
|
+
* fresh pages/css state so connected browsers pick up the fixed code without another edit.
|
|
387
|
+
*/
|
|
388
|
+
async announceRecoveredState(changedFiles: string[]): Promise<void> {
|
|
389
|
+
const generation = ++this.#generation;
|
|
390
|
+
await this.#enqueueWork("boot-recovered", async () => {
|
|
391
|
+
try {
|
|
392
|
+
const next = await new PagesBundleBuilder(this.#app).build();
|
|
393
|
+
process.send?.({
|
|
394
|
+
type: "pages-updated",
|
|
395
|
+
data: { bundlePath: next.bundlePath, buildId: next.buildId, generation, changedFiles },
|
|
396
|
+
});
|
|
397
|
+
this.#sendBuildStatus("pages", { generation, ok: true, files: changedFiles, message: "Boot build recovered" });
|
|
398
|
+
} catch (err) {
|
|
399
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
400
|
+
this.#logger.error(`recovered pages rebundle failed: ${message}`);
|
|
401
|
+
this.#sendBuildStatus("pages", { generation, ok: false, files: changedFiles, message });
|
|
402
|
+
}
|
|
403
|
+
this.scheduleCssRebuild(this.#artifactDir, { refresh: true, generation, changedFiles });
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
391
407
|
#shouldRebuildCsr() {
|
|
392
408
|
// CSR is served by `akn start`, so rebuild dev CSR artifacts until incremental CSR HMR is implemented.
|
|
393
409
|
return true;
|
|
394
410
|
}
|
|
395
411
|
|
|
396
|
-
static async
|
|
412
|
+
static async #buildBootDeps(app: App): Promise<IncrementalBuilderBootDeps> {
|
|
413
|
+
const { artifact, cssCompiler, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
|
|
414
|
+
await new CsrArtifactBuilder(app).build();
|
|
415
|
+
const discovery = await GraphClientEntryDiscovery.create(app);
|
|
416
|
+
return { artifact, cssCompiler, optimizedFonts, discovery };
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* A compile error in the boot build must not kill the builder: the builder is the dev server's
|
|
421
|
+
* file watcher, so exiting here leaves nothing to notice the fix. Report the failure, emit
|
|
422
|
+
* builder-ready so the host keeps the backend serving the last-good artifact, then retry the
|
|
423
|
+
* boot build on every file change until it succeeds.
|
|
424
|
+
*/
|
|
425
|
+
static #recoverBoot(
|
|
426
|
+
app: App,
|
|
427
|
+
bootError: unknown,
|
|
428
|
+
logger: Logger,
|
|
429
|
+
): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
|
|
430
|
+
const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
|
|
431
|
+
logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
|
|
432
|
+
let generation = 0;
|
|
433
|
+
const sendFailure = (files: string[], message: string) => {
|
|
434
|
+
process.send?.({
|
|
435
|
+
type: "build-status",
|
|
436
|
+
data: { generation, phase: "pages", ok: false, files, message: `Boot build failed: ${message}` },
|
|
437
|
+
});
|
|
438
|
+
};
|
|
439
|
+
sendFailure([], firstMessage);
|
|
440
|
+
process.send?.({ type: "builder-ready" });
|
|
441
|
+
return new Promise((resolve, reject) => {
|
|
442
|
+
void (async () => {
|
|
443
|
+
const roots = await new WatchRootResolver(app).resolve();
|
|
444
|
+
const watcher = new HmrWatcher({
|
|
445
|
+
roots,
|
|
446
|
+
logger,
|
|
447
|
+
onBatch: async (batch) => {
|
|
448
|
+
generation += 1;
|
|
449
|
+
const files = [...batch.files].sort();
|
|
450
|
+
try {
|
|
451
|
+
// A broken akan.config.ts caches its import failure; re-import it before rebuilding.
|
|
452
|
+
if (new Set(batch.kinds).has("config")) await app.getConfig({ refresh: true });
|
|
453
|
+
const deps = await IncrementalBuilder.#buildBootDeps(app);
|
|
454
|
+
const builder = new IncrementalBuilder({ app, watch: true, initialGeneration: generation, ...deps });
|
|
455
|
+
watcher.stop();
|
|
456
|
+
logger.info(`boot build recovered generation=${generation}`);
|
|
457
|
+
resolve({ builder, changedFiles: files });
|
|
458
|
+
} catch (err) {
|
|
459
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
460
|
+
logger.error(`boot build retry failed: ${message}`);
|
|
461
|
+
sendFailure(files, message);
|
|
462
|
+
}
|
|
463
|
+
},
|
|
464
|
+
});
|
|
465
|
+
watcher.start();
|
|
466
|
+
logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
|
|
467
|
+
})().catch(reject);
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
static async main(): Promise<void> {
|
|
472
|
+
const logger = new Logger("IncrementalBuilder");
|
|
397
473
|
const { appName, repoName, workspaceRoot } = WorkspaceExecutor.getBaseDevEnv();
|
|
398
474
|
if (!workspaceRoot || !appName) throw new Error("AKAN_WORKSPACE_ROOT or AKAN_PUBLIC_APP_NAME is not set");
|
|
399
475
|
const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot, repoName });
|
|
400
476
|
const app = AppExecutor.from(workspace, appName);
|
|
401
477
|
const watch = process.env.AKAN_WATCH !== "0";
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
478
|
+
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.
|
|
481
|
+
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
|
+
});
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
|
|
493
|
+
});
|
|
494
|
+
// The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
|
|
495
|
+
// as an orphaned watcher that keeps rebuilding for nobody.
|
|
496
|
+
process.on("disconnect", () => {
|
|
497
|
+
logger.warn("host IPC channel closed; exiting builder");
|
|
498
|
+
process.exit(0);
|
|
499
|
+
});
|
|
500
|
+
let recoveredFiles: string[] | null = null;
|
|
501
|
+
try {
|
|
502
|
+
builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app)) });
|
|
503
|
+
} catch (err) {
|
|
504
|
+
if (!watch) throw err;
|
|
505
|
+
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger);
|
|
506
|
+
builder = recovered.builder;
|
|
507
|
+
recoveredFiles = recovered.changedFiles;
|
|
508
|
+
}
|
|
509
|
+
await builder.boot();
|
|
510
|
+
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
406
511
|
}
|
|
407
512
|
}
|
|
408
513
|
|
|
409
|
-
void
|
|
514
|
+
void IncrementalBuilder.main().catch((err) => {
|
|
410
515
|
console.error(err);
|
|
411
516
|
process.exit(1);
|
|
412
517
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "2.4.0-rc.
|
|
3
|
+
"version": "2.4.0-rc.9",
|
|
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.
|
|
35
|
+
"akanjs": "2.4.0-rc.9",
|
|
36
36
|
"chalk": "^5.6.2",
|
|
37
37
|
"commander": "^14.0.3",
|
|
38
38
|
"daisyui": "5.5.23",
|