@akanjs/devkit 3.0.0-alpha.9 → 3.0.0-alpha.91
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/DEV_RUNTIME_KNOBS.md +27 -0
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/agentsIndex.test.ts +10 -0
- package/agentsIndex.ts +21 -1
- package/aiEditor.ts +1 -1
- package/akanApp/BackendImportGraph.test.ts +120 -0
- package/akanApp/BackendImportGraph.ts +167 -0
- package/akanApp/akanApp.host.test.ts +16 -678
- package/akanApp/akanApp.host.ts +113 -578
- package/akanApp/devHostPolicy.test.ts +542 -0
- package/akanApp/devHostPolicy.ts +442 -0
- package/akanApp/index.ts +2 -0
- package/akanConfig/akanConfig.test.ts +182 -14
- package/akanConfig/akanConfig.ts +143 -47
- package/akanConfig/types.ts +9 -0
- package/akanContext.ts +30 -14
- package/akanMcpContract.ts +25 -4
- package/appSelectionMemory.ts +28 -0
- package/applicationBuildRunner.test.ts +1 -1
- package/applicationBuildRunner.ts +45 -21
- package/artifact/implicitRootLayout.test.ts +67 -0
- package/artifact/implicitRootLayout.ts +27 -7
- package/artifact/routeSeedIndex.test.ts +1 -0
- package/biome.base.json +86 -47
- package/biomeBase.ts +6 -5
- package/capacitorApp.ts +10 -0
- package/cloud/globalConfig.ts +9 -2
- package/codegenLock.test.ts +137 -0
- package/codegenLock.ts +136 -0
- package/commandDecorators/argMeta.ts +9 -1
- package/commandDecorators/command.ts +94 -7
- package/commandDecorators/commandBuilder.ts +20 -5
- package/commandDecorators/commandDecorators.test.ts +115 -3
- package/commandDecorators/helpFormatter.ts +4 -0
- package/dependencyScanner.test.ts +99 -0
- package/dependencyScanner.ts +27 -24
- package/devkitUtils.test.ts +0 -104
- package/executors.test.ts +129 -4
- package/executors.ts +105 -24
- package/fileEditor.ts +19 -19
- package/fileSys.ts +19 -1
- package/formSetterScanner.test.ts +80 -0
- package/formSetterScanner.ts +92 -0
- package/frontendBuild/autoImportSync.test.ts +58 -0
- package/frontendBuild/autoImportSync.ts +7 -2
- package/frontendBuild/buildRouteClient.test.ts +47 -24
- package/frontendBuild/clientBuildTypes.ts +4 -0
- package/frontendBuild/clientEntriesBundler.ts +4 -1
- package/frontendBuild/clientEntryDiscovery.ts +2 -2
- package/frontendBuild/csrArtifactBuilder.ts +116 -84
- package/frontendBuild/cssCompiler.ts +123 -12
- package/frontendBuild/cssImportResolver.ts +8 -7
- package/frontendBuild/devGeneratedIndexSync.ts +3 -2
- package/frontendBuild/fontOptimizer.ts +37 -18
- package/frontendBuild/fontPruner.test.ts +220 -0
- package/frontendBuild/fontPruner.ts +206 -0
- package/frontendBuild/frontendBuild.test.ts +178 -10
- package/frontendBuild/hmrWatcher.ts +1 -1
- package/frontendBuild/index.ts +1 -1
- package/frontendBuild/pagesBundleBuilder.ts +3 -3
- package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
- package/frontendBuild/routeClientBuilder.ts +12 -5
- package/frontendBuild/sourceMtimeIndex.ts +1 -1
- package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
- package/frontendBuild/styleContract.ts +14 -20
- package/frontendBuild/themeValidator.ts +22 -17
- package/frontendBuild/vendorSpecifiers.ts +1 -0
- package/frontendBuild/watchRootResolver.test.ts +67 -0
- package/frontendBuild/watchRootResolver.ts +13 -1
- package/incrementalBuilder/devWatchBatch.test.ts +18 -20
- package/incrementalBuilder/devWatchBatch.ts +6 -2
- package/incrementalBuilder/incrementalBuilder.host.ts +51 -7
- package/incrementalBuilder/incrementalBuilder.proc.ts +17 -6
- package/index.ts +0 -7
- package/integration/devStabilityHarness.ts +2 -10
- package/libSource.test.ts +112 -0
- package/libSource.ts +126 -0
- package/lint/__fixtures__/README.md +40 -0
- package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
- package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
- package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
- package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
- package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
- package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
- package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
- package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
- package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
- package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
- package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
- package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
- package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
- package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
- package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
- package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
- package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
- package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
- package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
- package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
- package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
- package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
- package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
- package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
- package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
- package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
- package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
- package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
- package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
- package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
- package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
- package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
- package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
- package/lint/gritRules.test.ts +178 -0
- package/lint/no-arbitrary-color.grit +1 -1
- package/lint/no-async-component-in-ui.grit +35 -0
- package/lint/no-bang-comment-in-client.grit +23 -10
- package/lint/no-daisyui-legacy-class.grit +26 -9
- package/lint/no-deprecated-log-level.grit +17 -0
- package/lint/no-init-fetch-in-client.grit +47 -0
- package/lint/no-inline-color.grit +10 -8
- package/lint/no-interpolated-arbitrary-class.grit +3 -3
- package/lint/no-model-type-in-util-zone.grit +58 -0
- package/lint/no-raw-palette-class.grit +3 -3
- package/lint/no-unpublished-form-setter.grit +41 -0
- package/lint/non-scalar-props-restricted.grit +16 -7
- package/linter.test.ts +80 -0
- package/linter.ts +99 -25
- package/package.json +5 -8
- package/prompter.ts +9 -4
- package/qualityScanner.test.ts +116 -0
- package/qualityScanner.ts +89 -21
- package/recipeScanner.ts +4 -1
- package/scanInfo.ts +32 -26
- package/semver.test.ts +26 -0
- package/semver.ts +31 -0
- package/slicePlanner.test.ts +182 -0
- package/slicePlanner.ts +235 -0
- package/ssrScanner.test.ts +301 -0
- package/subspace.test.ts +418 -0
- package/subspace.ts +723 -0
- package/subspaceConfig.ts +76 -0
- package/transforms/asyncDefaultExportDetector.ts +103 -0
- package/transforms/barrelImportsPlugin.ts +1 -1
- package/transforms/externalizeFrameworkPlugin.ts +0 -1
- package/transforms/transforms.test.ts +5 -5
- package/transforms/tsconfigPackageResolver.test.ts +230 -0
- package/tsconfig.json +6 -2
- package/typeChecker.ts +1 -1
- package/types.ts +1 -0
- package/uploadRelease.ts +2 -2
- package/workflow/executor.test.ts +146 -0
- package/workspaceLayout.test.ts +56 -4
- package/workspaceLayout.ts +48 -2
- package/builder.ts +0 -164
- package/extractDeps.ts +0 -86
- package/frontendBuild/styleGuard.test.ts +0 -165
- package/frontendBuild/styleGuard.ts +0 -322
- package/getCredentials.ts +0 -19
- package/getModelFileData.ts +0 -62
- package/src/capacitorApp.ts +0 -282
- package/streamAi.ts +0 -45
- package/ui/MultiScrollList.tsx +0 -242
- package/ui/ScrollList.tsx +0 -107
- package/ui/index.ts +0 -2
- package/useStdoutDimensions.ts +0 -20
package/akanApp/akanApp.host.ts
CHANGED
|
@@ -1,15 +1,5 @@
|
|
|
1
|
-
import { stat } from "node:fs/promises";
|
|
2
|
-
import path from "node:path";
|
|
3
1
|
import { Logger } from "akanjs/common";
|
|
4
|
-
import type {
|
|
5
|
-
BuilderMessage,
|
|
6
|
-
BuilderMetrics,
|
|
7
|
-
BuildPhase,
|
|
8
|
-
ChangeBatch,
|
|
9
|
-
DevBuildStatus,
|
|
10
|
-
DevChangePlan,
|
|
11
|
-
DevChangeRole,
|
|
12
|
-
} from "akanjs/server";
|
|
2
|
+
import type { BuilderMessage, BuilderMetrics, BuildPhase, ChangeBatch, DevBuildStatus } from "akanjs/server";
|
|
13
3
|
import type { App } from "../commandDecorators";
|
|
14
4
|
import { createTunnel } from "../createTunnel";
|
|
15
5
|
import { WorkspaceExecutor } from "../executors";
|
|
@@ -17,31 +7,67 @@ import { WorkspaceExecutor } from "../executors";
|
|
|
17
7
|
// tailwind stack, which is exactly what a suspended dev host must not be holding.
|
|
18
8
|
import { HmrWatcher } from "../frontendBuild/hmrWatcher";
|
|
19
9
|
import { WatchRootResolver } from "../frontendBuild/watchRootResolver";
|
|
20
|
-
import {
|
|
10
|
+
import { type DevStdioMode, IncrementalBuilderHost } from "../incrementalBuilder";
|
|
21
11
|
import { BuilderRequestRouter } from "../incrementalBuilder/builderRequestRouter";
|
|
12
|
+
import { BackendImportGraph } from "./BackendImportGraph";
|
|
13
|
+
import {
|
|
14
|
+
type BackendLifecycleState,
|
|
15
|
+
type BackendRestartReason,
|
|
16
|
+
BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS,
|
|
17
|
+
backendRestartReasonFromMessage,
|
|
18
|
+
buildStatusReplaySequence,
|
|
19
|
+
createBackendBuildStatus,
|
|
20
|
+
type DevHostEvent,
|
|
21
|
+
type DevHostState,
|
|
22
|
+
decideBuilderRssRecycle,
|
|
23
|
+
decideBuilderRssSettle,
|
|
24
|
+
decideIdleSuspend,
|
|
25
|
+
devHostStateOf,
|
|
26
|
+
filesChangedSince,
|
|
27
|
+
hasAnyBuildFailure,
|
|
28
|
+
hasBuildFailureForGeneration,
|
|
29
|
+
isLegacyBackendFallbackFile,
|
|
30
|
+
isRssCeilingUnreachable,
|
|
31
|
+
mergeBackendRestartReasons,
|
|
32
|
+
mergeInvalidateMessages,
|
|
33
|
+
normalizeBackendReportedGeneration,
|
|
34
|
+
resolveIdleSuspendMs,
|
|
35
|
+
type SourceFingerprints,
|
|
36
|
+
shouldAbandonBackendRecovery,
|
|
37
|
+
shouldHoldForReturningBuilder,
|
|
38
|
+
shouldMarkBuildPhaseRecovered,
|
|
39
|
+
shouldQueueBuildStatusReplay,
|
|
40
|
+
shouldRefreshConfigOnIdleWake,
|
|
41
|
+
shouldRelayRecycledFrontendState,
|
|
42
|
+
shouldReplaceLastGoodMessage,
|
|
43
|
+
shouldRestartBackendByDevPlan,
|
|
44
|
+
shouldRestartBuilderByDevPlan,
|
|
45
|
+
shouldRestartDevHostByDevPlan,
|
|
46
|
+
shouldWarnBuilderRssCeilingTight,
|
|
47
|
+
} from "./devHostPolicy";
|
|
22
48
|
|
|
23
49
|
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route", "build-csr"]);
|
|
50
|
+
|
|
24
51
|
const BACKEND_RESTART_DEBOUNCE_MS = 120;
|
|
52
|
+
|
|
25
53
|
// Must exceed the gateway's child-wait budget (AkanApp child shutdown, ~5s in dev) so the gateway
|
|
26
54
|
// is never SIGKILLed while its replicas are still shutting down — that's what strands orphans.
|
|
27
55
|
const BACKEND_GRACEFUL_TIMEOUT_MS = 8_000;
|
|
56
|
+
|
|
28
57
|
const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
58
|
+
|
|
29
59
|
const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
|
|
30
|
-
|
|
60
|
+
|
|
31
61
|
const BACKEND_STDERR_TAIL_LIMIT = 40;
|
|
62
|
+
|
|
32
63
|
const BUILDER_READY_TIMEOUT_MS = 150000;
|
|
64
|
+
|
|
33
65
|
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
34
|
-
|
|
35
|
-
* How many requests may wait for a builder that is coming back. Generous — a page load asks for
|
|
36
|
-
* several routes — but finite, so a builder that never returns cannot grow this without bound. Past
|
|
37
|
-
* it, requests are failed as they were before, which is the behaviour this limit falls back to.
|
|
38
|
-
*/
|
|
39
|
-
const HELD_BUILDER_REQUEST_LIMIT = 64;
|
|
66
|
+
|
|
40
67
|
// Save-on-keystroke arrives as a burst of batches. Recycling mid-burst would drop the watcher events
|
|
41
68
|
// still on their way to the builder, so an over-ceiling builder is replaced only once it goes quiet.
|
|
42
69
|
const BUILDER_RSS_RECYCLE_QUIET_MS = 750;
|
|
43
|
-
|
|
44
|
-
const BUILDER_TIGHT_RSS_REPORT_LIMIT = 2;
|
|
70
|
+
|
|
45
71
|
// Linux hands the bundler arenas back on its own after ~10-15s idle — measured at 46-59% of the
|
|
46
72
|
// builder's peak (`local/optimize-resource/09-linux-retention-measurement.md`) — while macOS returns
|
|
47
73
|
// none of it. The builder only reports RSS at work-completion points, so the sample a recycle is armed
|
|
@@ -49,564 +75,28 @@ const BUILDER_TIGHT_RSS_REPORT_LIMIT = 2;
|
|
|
49
75
|
// paying a cold boot build for memory the OS was about to return anyway. On macOS the re-read returns
|
|
50
76
|
// the same value, so this only ever costs the delay.
|
|
51
77
|
const BUILDER_RSS_SETTLE_MS = 20_000;
|
|
78
|
+
|
|
52
79
|
/** Reading one process's rss is a millisecond of work; anything near this is a `ps` that is stuck. */
|
|
53
80
|
const PS_RSS_TIMEOUT_MS = 2_000;
|
|
54
|
-
|
|
55
|
-
const BUILDER_RSS_HARD_MULTIPLE = 1.5;
|
|
56
|
-
// A sandbox between user turns pays for a watcher that is watching nothing change. Suspending build
|
|
57
|
-
// capacity after this long returns the builder's residency until the next edit or route request.
|
|
58
|
-
const DEV_IDLE_SUSPEND_MS = 300_000;
|
|
59
|
-
// A wake that immediately suspends again would flap around whatever woke it.
|
|
60
|
-
const DEV_IDLE_MIN_UPTIME_MS = 30_000;
|
|
81
|
+
|
|
61
82
|
// The builder is the file watcher: while it is down no edit can trigger a retry, so unlike the
|
|
62
83
|
// backend the recovery loop never gives up — it only backs off.
|
|
63
84
|
const BUILDER_RECOVERY_BASE_DELAY_MS = 2_000;
|
|
64
|
-
const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
|
|
65
|
-
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
66
|
-
const NON_SOURCE_EXT_RE =
|
|
67
|
-
/\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
|
|
68
|
-
const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
|
|
69
|
-
const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
|
|
70
|
-
const RUNTIME_METADATA_BASENAMES = new Set(["dict.ts", "sig.ts", "useClient.ts", "useServer.ts"]);
|
|
71
|
-
const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
|
|
72
|
-
"import-statement",
|
|
73
|
-
"require-call",
|
|
74
|
-
"require-resolve",
|
|
75
|
-
"dynamic-import",
|
|
76
|
-
]);
|
|
77
|
-
|
|
78
|
-
export const shouldRestartBackendByDevPlan = (
|
|
79
|
-
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
80
|
-
): boolean | null => {
|
|
81
|
-
if (!message.devPlan) return null;
|
|
82
|
-
if (message.devPlan.actions.includes("report-error")) return false;
|
|
83
|
-
if (message.devPlan.actions.includes("restart-builder")) return false;
|
|
84
|
-
return message.devPlan.actions.includes("restart-backend");
|
|
85
|
-
};
|
|
86
|
-
|
|
87
|
-
export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
|
|
88
|
-
message.devPlan?.actions.includes("restart-builder") ?? false;
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* A backend that keeps dying isn't going to heal by retrying the same code; after this many
|
|
92
|
-
* consecutive attempts the host idles and the next server-side edit triggers a fresh restart.
|
|
93
|
-
*/
|
|
94
|
-
export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
|
|
95
|
-
attempts >= maxAttempts;
|
|
96
85
|
|
|
97
|
-
|
|
98
|
-
export const normalizeBackendReportedGeneration = (generation: number): number | undefined =>
|
|
99
|
-
generation >= 0 ? generation : undefined;
|
|
100
|
-
|
|
101
|
-
export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
|
|
102
|
-
message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
|
|
103
|
-
|
|
104
|
-
export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "stopping" | "recovering" | "stopped";
|
|
105
|
-
|
|
106
|
-
export interface BackendRestartReason {
|
|
107
|
-
generation?: number;
|
|
108
|
-
files: string[];
|
|
109
|
-
roles: Extract<DevChangeRole, "server" | "shared" | "barrel" | "config">[];
|
|
110
|
-
}
|
|
86
|
+
const BUILDER_RECOVERY_MAX_DELAY_MS = 60_000;
|
|
111
87
|
|
|
112
88
|
interface LastGoodFrontendState {
|
|
113
89
|
pages?: Extract<BuilderMessage, { type: "pages-updated" }>;
|
|
114
90
|
css?: Extract<BuilderMessage, { type: "css-updated" }>;
|
|
115
91
|
}
|
|
116
92
|
|
|
117
|
-
const RESTART_ROLE_ORDER: BackendRestartReason["roles"] = ["server", "shared", "barrel", "config"];
|
|
118
|
-
|
|
119
|
-
const generationValue = (generation: number | undefined): number => generation ?? -1;
|
|
120
|
-
|
|
121
|
-
export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string): boolean => {
|
|
122
|
-
const abs = path.resolve(file);
|
|
123
|
-
const ext = path.extname(abs).toLowerCase();
|
|
124
|
-
if (!SOURCE_EXTS.has(ext)) return false;
|
|
125
|
-
const rel = path.relative(path.resolve(workspaceRoot), abs);
|
|
126
|
-
if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
127
|
-
const parts = rel.split(path.sep).filter(Boolean);
|
|
128
|
-
const [scope] = parts;
|
|
129
|
-
if (scope !== "apps" && scope !== "libs" && scope !== "pkgs") return false;
|
|
130
|
-
|
|
131
|
-
const base = path.basename(abs);
|
|
132
|
-
return (
|
|
133
|
-
parts.includes("srvkit") ||
|
|
134
|
-
parts.includes("common") ||
|
|
135
|
-
SERVER_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
|
|
136
|
-
SHARED_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
|
|
137
|
-
RUNTIME_METADATA_BASENAMES.has(base) ||
|
|
138
|
-
base === "main.ts" ||
|
|
139
|
-
base === "server.ts"
|
|
140
|
-
);
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
export const shouldMarkBuildPhaseRecovered = (
|
|
144
|
-
previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
145
|
-
status: DevBuildStatus,
|
|
146
|
-
): boolean => {
|
|
147
|
-
const previous = previousByPhase.get(status.phase);
|
|
148
|
-
return Boolean(previous && status.ok && !previous.ok && generationValue(status.generation) >= previous.generation);
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
export const createBackendBuildStatus = ({
|
|
152
|
-
generation,
|
|
153
|
-
ok,
|
|
154
|
-
files = [],
|
|
155
|
-
message,
|
|
156
|
-
}: {
|
|
157
|
-
generation: number;
|
|
158
|
-
ok: boolean;
|
|
159
|
-
files?: string[];
|
|
160
|
-
message?: string;
|
|
161
|
-
}): DevBuildStatus => ({
|
|
162
|
-
generation,
|
|
163
|
-
phase: "backend",
|
|
164
|
-
ok,
|
|
165
|
-
files,
|
|
166
|
-
message,
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
export const backendRestartReasonFromMessage = (
|
|
170
|
-
message: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
171
|
-
): BackendRestartReason => {
|
|
172
|
-
const roleSet = new Set<BackendRestartReason["roles"][number]>();
|
|
173
|
-
for (const role of message.devPlan?.roles ?? []) {
|
|
174
|
-
if (role === "server" || role === "shared" || role === "barrel" || role === "config") roleSet.add(role);
|
|
175
|
-
}
|
|
176
|
-
return {
|
|
177
|
-
generation: message.devPlan?.generation ?? message.generation,
|
|
178
|
-
files: [...new Set(message.files)].sort(),
|
|
179
|
-
roles: RESTART_ROLE_ORDER.filter((role) => roleSet.has(role)),
|
|
180
|
-
};
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
export const mergeBackendRestartReasons = (
|
|
184
|
-
current: BackendRestartReason | null,
|
|
185
|
-
next: BackendRestartReason,
|
|
186
|
-
): BackendRestartReason => ({
|
|
187
|
-
generation:
|
|
188
|
-
generationValue(next.generation) >= generationValue(current?.generation) ? next.generation : current?.generation,
|
|
189
|
-
files: [...new Set([...(current?.files ?? []), ...next.files])].sort(),
|
|
190
|
-
roles: RESTART_ROLE_ORDER.filter((role) => current?.roles.includes(role) || next.roles.includes(role)),
|
|
191
|
-
});
|
|
192
|
-
|
|
193
|
-
export const shouldReplaceLastGoodMessage = (
|
|
194
|
-
current:
|
|
195
|
-
| Extract<BuilderMessage, { type: "pages-updated" }>
|
|
196
|
-
| Extract<BuilderMessage, { type: "css-updated" }>
|
|
197
|
-
| undefined,
|
|
198
|
-
next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
199
|
-
): boolean => !current || generationValue(next.data.generation) >= generationValue(current.data.generation);
|
|
200
|
-
|
|
201
|
-
export const shouldQueueBuildStatusReplay = (backendReady: boolean, pendingReplayCount: number): boolean =>
|
|
202
|
-
!backendReady || pendingReplayCount > 0;
|
|
203
|
-
|
|
204
|
-
/**
|
|
205
|
-
* Recycling the builder/backend on a generation whose build already failed is guaranteed to strand
|
|
206
|
-
* the dev server: the rebooted builder hits the same compile error and exits before builder-ready.
|
|
207
|
-
* Failing phase statuses for a generation arrive over IPC before that generation's invalidate, so
|
|
208
|
-
* the host can check them here and defer the recycle until a healthy batch lands.
|
|
209
|
-
*/
|
|
210
|
-
export const hasBuildFailureForGeneration = (
|
|
211
|
-
statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
212
|
-
generation: number | undefined,
|
|
213
|
-
): boolean => {
|
|
214
|
-
if (typeof generation !== "number") return false;
|
|
215
|
-
for (const status of statusByPhase.values()) {
|
|
216
|
-
if (!status.ok && status.generation === generation) return true;
|
|
217
|
-
}
|
|
218
|
-
return false;
|
|
219
|
-
};
|
|
220
|
-
|
|
221
|
-
export type BuilderRssRecycleDecision = "unbounded" | "below-ceiling" | "build-failed" | "too-soon" | "recycle";
|
|
222
|
-
|
|
223
|
-
/**
|
|
224
|
-
* Whether an over-ceiling builder should be replaced now.
|
|
225
|
-
*
|
|
226
|
-
* `Bun.build` never returns its native arenas, so the builder's RSS only comes back when the process
|
|
227
|
-
* exits. Recycling it is therefore the only bound available — but it costs a boot build, so the two
|
|
228
|
-
* cases where a recycle cannot help are excluded: a generation whose build already failed (the
|
|
229
|
-
* replacement would hit the same compile error), and a recycle so soon after the last one that the
|
|
230
|
-
* ceiling is evidently unreachable for this app.
|
|
231
|
-
*/
|
|
232
|
-
export const decideBuilderRssRecycle = ({
|
|
233
|
-
rssBytes,
|
|
234
|
-
ceilingBytes,
|
|
235
|
-
buildFailed,
|
|
236
|
-
msSinceLastRecycle,
|
|
237
|
-
minIntervalMs = BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS,
|
|
238
|
-
}: {
|
|
239
|
-
rssBytes: number;
|
|
240
|
-
ceilingBytes: number | null;
|
|
241
|
-
buildFailed: boolean;
|
|
242
|
-
msSinceLastRecycle: number | null;
|
|
243
|
-
minIntervalMs?: number;
|
|
244
|
-
}): BuilderRssRecycleDecision => {
|
|
245
|
-
if (!ceilingBytes) return "unbounded";
|
|
246
|
-
if (rssBytes < ceilingBytes) return "below-ceiling";
|
|
247
|
-
if (buildFailed) return "build-failed";
|
|
248
|
-
if (msSinceLastRecycle !== null && msSinceLastRecycle < minIntervalMs) return "too-soon";
|
|
249
|
-
return "recycle";
|
|
250
|
-
};
|
|
251
|
-
|
|
252
|
-
export type BuilderRssSettleDecision = "recycle-now" | "wait-and-recheck";
|
|
253
|
-
|
|
254
|
-
/**
|
|
255
|
-
* Whether an armed recycle should wait out the allocator's purge window before committing. A builder
|
|
256
|
-
* far enough over the ceiling is not going to be rescued by a purge, so waiting there only delays a
|
|
257
|
-
* recycle that has to happen.
|
|
258
|
-
*/
|
|
259
|
-
export const decideBuilderRssSettle = ({
|
|
260
|
-
rssBytes,
|
|
261
|
-
ceilingBytes,
|
|
262
|
-
hardMultiple = BUILDER_RSS_HARD_MULTIPLE,
|
|
263
|
-
}: {
|
|
264
|
-
rssBytes: number;
|
|
265
|
-
ceilingBytes: number;
|
|
266
|
-
hardMultiple?: number;
|
|
267
|
-
}): BuilderRssSettleDecision => (rssBytes >= ceilingBytes * hardMultiple ? "recycle-now" : "wait-and-recheck");
|
|
268
|
-
|
|
269
|
-
export type IdleSuspendDecision =
|
|
270
|
-
| "disabled"
|
|
271
|
-
| "already-suspended"
|
|
272
|
-
| "builder-not-ready"
|
|
273
|
-
| "backend-not-ready"
|
|
274
|
-
| "build-failed"
|
|
275
|
-
| "restart-pending"
|
|
276
|
-
| "too-soon"
|
|
277
|
-
| "suspend";
|
|
278
|
-
|
|
279
|
-
/**
|
|
280
|
-
* Whether the dev host may drop its build capacity now. Every "no" here is a case where suspending
|
|
281
|
-
* would either lose work or produce a wake that immediately re-suspends:
|
|
282
|
-
*
|
|
283
|
-
* - a red build means the developer is mid-fix and about to save again, and a wake would boot straight
|
|
284
|
-
* back into the same error via the degraded-boot path
|
|
285
|
-
* - a pending restart/recovery already has its own plan for the builder
|
|
286
|
-
* - `too-soon` keeps a wake from flapping around whatever triggered it
|
|
287
|
-
*/
|
|
288
|
-
export const decideIdleSuspend = ({
|
|
289
|
-
enabled,
|
|
290
|
-
suspended,
|
|
291
|
-
builderReady,
|
|
292
|
-
backendReady,
|
|
293
|
-
buildFailed,
|
|
294
|
-
restartPending,
|
|
295
|
-
msSinceWake,
|
|
296
|
-
minUptimeMs = DEV_IDLE_MIN_UPTIME_MS,
|
|
297
|
-
}: {
|
|
298
|
-
enabled: boolean;
|
|
299
|
-
suspended: boolean;
|
|
300
|
-
builderReady: boolean;
|
|
301
|
-
backendReady: boolean;
|
|
302
|
-
buildFailed: boolean;
|
|
303
|
-
restartPending: boolean;
|
|
304
|
-
msSinceWake: number | null;
|
|
305
|
-
minUptimeMs?: number;
|
|
306
|
-
}): IdleSuspendDecision => {
|
|
307
|
-
if (!enabled) return "disabled";
|
|
308
|
-
if (suspended) return "already-suspended";
|
|
309
|
-
if (!builderReady) return "builder-not-ready";
|
|
310
|
-
if (!backendReady) return "backend-not-ready";
|
|
311
|
-
if (buildFailed) return "build-failed";
|
|
312
|
-
if (restartPending) return "restart-pending";
|
|
313
|
-
if (msSinceWake !== null && msSinceWake < minUptimeMs) return "too-soon";
|
|
314
|
-
return "suspend";
|
|
315
|
-
};
|
|
316
|
-
|
|
317
|
-
/** `undefined` env means the default is on; any non-positive value turns idle suspend off. */
|
|
318
|
-
export const resolveIdleSuspendMs = (raw: string | undefined): number | null => {
|
|
319
|
-
if (raw === undefined || raw === "") return DEV_IDLE_SUSPEND_MS;
|
|
320
|
-
const parsed = Number(raw);
|
|
321
|
-
if (!Number.isFinite(parsed) || parsed <= 0) return null;
|
|
322
|
-
return Math.round(parsed);
|
|
323
|
-
};
|
|
324
|
-
|
|
325
|
-
/** Any red phase blocks a suspend, unlike the rss recycle which only cares about one generation. */
|
|
326
|
-
export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
|
|
327
|
-
[...statusByPhase.values()].some((status) => !status.ok);
|
|
328
|
-
|
|
329
|
-
/**
|
|
330
|
-
* A config change while suspended cannot be applied by restarting the builder alone — the dev host
|
|
331
|
-
* itself has to re-read the config, which is the same path an ordinary config save takes.
|
|
332
|
-
*/
|
|
333
|
-
export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
|
|
334
|
-
!!batch && batch.kinds.has("config");
|
|
335
|
-
|
|
336
|
-
/**
|
|
337
|
-
* Whether a request that arrived while the builder was away should wait for the one coming back.
|
|
338
|
-
*
|
|
339
|
-
* A recycle or a crash-restart is a gap, not a failure — the request that lands in it is the page a
|
|
340
|
-
* developer is waiting on. A stopped builder is a different thing: nothing is bringing it back, so
|
|
341
|
-
* waiting would only delay the error.
|
|
342
|
-
*
|
|
343
|
-
* `recycling` is in here for the same reason as `restarting`, and was the hole this decision shipped
|
|
344
|
-
* with: a draining builder is still alive, so the request reached it and came back refused while the
|
|
345
|
-
* host still thought there was nothing to wait for.
|
|
346
|
-
*/
|
|
347
|
-
const RETURNING_BUILDER_STATUSES = new Set<IncrementalBuilderStatus>(["starting", "recycling", "restarting"]);
|
|
348
|
-
export const shouldHoldForReturningBuilder = ({
|
|
349
|
-
status,
|
|
350
|
-
heldCount,
|
|
351
|
-
limit = HELD_BUILDER_REQUEST_LIMIT,
|
|
352
|
-
}: {
|
|
353
|
-
status: IncrementalBuilderStatus;
|
|
354
|
-
heldCount: number;
|
|
355
|
-
limit?: number;
|
|
356
|
-
}): boolean => RETURNING_BUILDER_STATUSES.has(status) && heldCount < limit;
|
|
357
|
-
|
|
358
|
-
/**
|
|
359
|
-
* Whether a builder that is over the ceiling again this soon after being replaced is worth saying so
|
|
360
|
-
* about, once. Not a reason to stop enforcing the ceiling: the minimum interval already bounds what
|
|
361
|
-
* this costs at one recycle per interval, and dropping the bound is how a container gets OOM-killed.
|
|
362
|
-
*
|
|
363
|
-
* This used to disable the ceiling for the session, on a count that a single page load reaches — two
|
|
364
|
-
* route builds, two reports, both inside the interval. That is normal work on any app whose builds sit
|
|
365
|
-
* above the ceiling, which is the same app the ceiling was derived for.
|
|
366
|
-
*/
|
|
367
|
-
export const shouldWarnBuilderRssCeilingTight = (
|
|
368
|
-
reportsSinceRecycle: number,
|
|
369
|
-
limit = BUILDER_TIGHT_RSS_REPORT_LIMIT,
|
|
370
|
-
): boolean => reportsSinceRecycle >= limit;
|
|
371
|
-
|
|
372
|
-
/**
|
|
373
|
-
* Whether recycling can ever bring this builder under the ceiling.
|
|
374
|
-
*
|
|
375
|
-
* Measured on a replacement the moment it is ready, before it has built anything on demand: that is
|
|
376
|
-
* the floor every future replacement lands on, so a floor already over the ceiling is the one case
|
|
377
|
-
* where the recycle loop is pure cost. It is also the case the escape hatch was always described as
|
|
378
|
-
* being for — the previous rule inferred it from report timing and caught ordinary work instead.
|
|
379
|
-
*/
|
|
380
|
-
export const isRssCeilingUnreachable = (freshRssBytes: number | null, ceilingBytes: number | null): boolean =>
|
|
381
|
-
freshRssBytes !== null && ceilingBytes !== null && freshRssBytes >= ceilingBytes;
|
|
382
|
-
|
|
383
|
-
/**
|
|
384
|
-
* Whether a recycled builder's re-announced boot artifact actually differs from what the backend
|
|
385
|
-
* already has. Both payload identities are content hashes — `pages-[hash].js` and
|
|
386
|
-
* `<name>-[hash].css` — so an unchanged recycle produces identical ones and needs no reload. Only a
|
|
387
|
-
* save that raced the recycle moves them, and that is the case worth pushing.
|
|
388
|
-
*/
|
|
389
|
-
export const shouldRelayRecycledFrontendState = (
|
|
390
|
-
current:
|
|
391
|
-
| Extract<BuilderMessage, { type: "pages-updated" }>
|
|
392
|
-
| Extract<BuilderMessage, { type: "css-updated" }>
|
|
393
|
-
| undefined,
|
|
394
|
-
next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
|
|
395
|
-
): boolean => {
|
|
396
|
-
if (!current || current.type !== next.type) return true;
|
|
397
|
-
if (current.type === "pages-updated" && next.type === "pages-updated")
|
|
398
|
-
return current.data.bundlePath !== next.data.bundlePath;
|
|
399
|
-
if (current.type === "css-updated" && next.type === "css-updated")
|
|
400
|
-
return JSON.stringify(current.data.cssAssets) !== JSON.stringify(next.data.cssAssets);
|
|
401
|
-
return true;
|
|
402
|
-
};
|
|
403
|
-
|
|
404
|
-
const mergeDevPlans = (current?: DevChangePlan, next?: DevChangePlan): DevChangePlan | undefined => {
|
|
405
|
-
if (!current) return next;
|
|
406
|
-
if (!next) return current;
|
|
407
|
-
const reasonByFile: Record<string, string[]> = { ...current.reasonByFile };
|
|
408
|
-
for (const [file, reasons] of Object.entries(next.reasonByFile)) {
|
|
409
|
-
reasonByFile[file] = [...new Set([...(reasonByFile[file] ?? []), ...reasons])].sort();
|
|
410
|
-
}
|
|
411
|
-
return {
|
|
412
|
-
generation: Math.max(current.generation, next.generation),
|
|
413
|
-
files: [...new Set([...current.files, ...next.files])].sort(),
|
|
414
|
-
generatedFiles: [...new Set([...current.generatedFiles, ...next.generatedFiles])].sort(),
|
|
415
|
-
roles: [...new Set([...current.roles, ...next.roles])].sort(),
|
|
416
|
-
actions: [...new Set([...current.actions, ...next.actions])].sort(),
|
|
417
|
-
reasonByFile,
|
|
418
|
-
};
|
|
419
|
-
};
|
|
420
|
-
|
|
421
|
-
/** A deferred recycle accumulates every batch it skipped so the eventual restart covers them all. */
|
|
422
|
-
export const mergeInvalidateMessages = (
|
|
423
|
-
current: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
424
|
-
next: Extract<BuilderMessage, { type: "invalidate" }>,
|
|
425
|
-
): Extract<BuilderMessage, { type: "invalidate" }> => {
|
|
426
|
-
const generation = Math.max(generationValue(current.generation), generationValue(next.generation));
|
|
427
|
-
return {
|
|
428
|
-
type: "invalidate",
|
|
429
|
-
kinds: [...new Set([...current.kinds, ...next.kinds])].sort(),
|
|
430
|
-
files: [...new Set([...current.files, ...next.files])].sort(),
|
|
431
|
-
generation: generation >= 0 ? generation : undefined,
|
|
432
|
-
devPlan: mergeDevPlans(current.devPlan, next.devPlan),
|
|
433
|
-
};
|
|
434
|
-
};
|
|
435
|
-
|
|
436
|
-
export const buildStatusReplaySequence = (
|
|
437
|
-
pendingReplay: readonly DevBuildStatus[],
|
|
438
|
-
latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
|
|
439
|
-
): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
|
|
440
|
-
|
|
441
|
-
/** `(mtimeMs, size)` per file — what a save moves, and what a rebuild of identical content does not. */
|
|
442
|
-
export type SourceFingerprints = ReadonlyMap<string, string>;
|
|
443
|
-
|
|
444
|
-
/**
|
|
445
|
-
* Which of the files in `before` are no longer stamped the way they were.
|
|
446
|
-
*
|
|
447
|
-
* Only files present in `before` are compared. The question this answers is which *running* code went
|
|
448
|
-
* stale while nothing was watching, and a file that did not exist then is not running anywhere.
|
|
449
|
-
*/
|
|
450
|
-
export const filesChangedSince = (before: SourceFingerprints, after: SourceFingerprints): string[] =>
|
|
451
|
-
[...before].filter(([file, stamp]) => after.get(file) !== stamp).map(([file]) => file);
|
|
452
|
-
|
|
453
|
-
export class BackendImportGraph {
|
|
454
|
-
readonly #app: App;
|
|
455
|
-
readonly #logger: Logger;
|
|
456
|
-
readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
|
|
457
|
-
readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
|
|
458
|
-
readonly #jsTranspiler = new Bun.Transpiler({ loader: "js" });
|
|
459
|
-
readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
|
|
460
|
-
#files = new Set<string>();
|
|
461
|
-
/**
|
|
462
|
-
* `refresh()` runs on every server-side save and on every dev-host recycle, and re-reading plus
|
|
463
|
-
* re-parsing files that did not change is the whole cost of it. Keyed on (mtimeMs, size).
|
|
464
|
-
*
|
|
465
|
-
* Specifiers are cached, not resolved paths: creating a file can change what an *unchanged* importer's
|
|
466
|
-
* specifier resolves to, and `Bun.resolveSync` is cheap next to a read plus a transpiler scan. Only
|
|
467
|
-
* the scan result is retained — never the source text.
|
|
468
|
-
*/
|
|
469
|
-
#scanCache = new Map<string, { mtimeMs: number; size: number; specifiers: Bun.Import[] }>();
|
|
470
|
-
#ready = false;
|
|
471
|
-
#lastRefreshSucceeded = false;
|
|
472
|
-
|
|
473
|
-
constructor(app: App, logger: Logger) {
|
|
474
|
-
this.#app = app;
|
|
475
|
-
this.#logger = logger;
|
|
476
|
-
}
|
|
477
|
-
|
|
478
|
-
get ready() {
|
|
479
|
-
return this.#ready;
|
|
480
|
-
}
|
|
481
|
-
|
|
482
|
-
get lastRefreshSucceeded() {
|
|
483
|
-
return this.#lastRefreshSucceeded;
|
|
484
|
-
}
|
|
485
|
-
|
|
486
|
-
has(file: string) {
|
|
487
|
-
return this.#files.has(path.resolve(file));
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* Stamp every file the backend runs, so a caller can ask later what moved.
|
|
492
|
-
*
|
|
493
|
-
* Taken when the builder goes away and compared when its replacement is up, because nothing watches
|
|
494
|
-
* the tree in between: the departing builder's watcher left with it, and the replacement's index
|
|
495
|
-
* primes from the disk it finds, so an edit that lands in the gap is *baseline* to it and is never
|
|
496
|
-
* reported at all. The client half of such an edit is rescued by the replacement's boot build; the
|
|
497
|
-
* backend half is a server left running code that no longer exists, with nothing on screen to say so.
|
|
498
|
-
*
|
|
499
|
-
* One `stat` per graph file, against a gap that costs a whole boot build anyway.
|
|
500
|
-
*/
|
|
501
|
-
async fingerprint(): Promise<SourceFingerprints> {
|
|
502
|
-
const stamps = await Promise.all(
|
|
503
|
-
[...this.#files].map(async (file) => {
|
|
504
|
-
const stats = await stat(file).catch(() => null);
|
|
505
|
-
return [file, stats ? `${Math.round(stats.mtimeMs)}:${stats.size}` : "(gone)"] as const;
|
|
506
|
-
}),
|
|
507
|
-
);
|
|
508
|
-
return new Map(stamps);
|
|
509
|
-
}
|
|
510
|
-
|
|
511
|
-
async refresh(): Promise<boolean> {
|
|
512
|
-
try {
|
|
513
|
-
const files = await this.#build();
|
|
514
|
-
this.#files = files;
|
|
515
|
-
this.#ready = true;
|
|
516
|
-
this.#lastRefreshSucceeded = true;
|
|
517
|
-
this.#logger.verbose(`[backend-graph] scanned ${files.size} files`);
|
|
518
|
-
return true;
|
|
519
|
-
} catch (err) {
|
|
520
|
-
this.#ready = this.#files.size > 0;
|
|
521
|
-
this.#lastRefreshSucceeded = false;
|
|
522
|
-
this.#logger.warn(
|
|
523
|
-
`[backend-graph] scan failed; ${this.#ready ? "using previous graph" : "using fallback rules"}: ${err instanceof Error ? err.message : String(err)}`,
|
|
524
|
-
);
|
|
525
|
-
return this.#ready;
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
async #build(): Promise<Set<string>> {
|
|
530
|
-
const roots = await this.#entrypoints();
|
|
531
|
-
const files = new Set<string>();
|
|
532
|
-
const queue = [...roots];
|
|
533
|
-
const workspaceRoot = path.resolve(this.#app.workspace.workspaceRoot);
|
|
534
|
-
|
|
535
|
-
while (queue.length > 0) {
|
|
536
|
-
const current = path.resolve(queue.pop() as string);
|
|
537
|
-
if (files.has(current)) continue;
|
|
538
|
-
if (!this.#isWorkspaceSource(current, workspaceRoot)) continue;
|
|
539
|
-
const imports = await this.#importsOf(current);
|
|
540
|
-
if (!imports) continue;
|
|
541
|
-
|
|
542
|
-
files.add(current);
|
|
543
|
-
const importerDir = path.dirname(current);
|
|
544
|
-
for (const imp of imports) {
|
|
545
|
-
if (!GRAPH_IMPORT_KINDS.has(imp.kind) || !imp.path || NON_SOURCE_EXT_RE.test(imp.path)) continue;
|
|
546
|
-
const resolved = this.#resolve(imp.path, importerDir);
|
|
547
|
-
if (!resolved || files.has(resolved)) continue;
|
|
548
|
-
queue.push(resolved);
|
|
549
|
-
}
|
|
550
|
-
}
|
|
551
|
-
// Files that dropped out of the graph would otherwise be cached for the life of the dev session.
|
|
552
|
-
for (const cached of this.#scanCache.keys()) if (!files.has(cached)) this.#scanCache.delete(cached);
|
|
553
|
-
return files;
|
|
554
|
-
}
|
|
555
|
-
|
|
556
|
-
/** Null when the file is gone, which is the existence check the walk used to make separately. */
|
|
557
|
-
async #importsOf(file: string): Promise<Bun.Import[] | null> {
|
|
558
|
-
const stats = await stat(file).catch(() => null);
|
|
559
|
-
if (!stats?.isFile()) return null;
|
|
560
|
-
const mtimeMs = Math.round(stats.mtimeMs);
|
|
561
|
-
const cached = this.#scanCache.get(file);
|
|
562
|
-
if (cached && cached.mtimeMs === mtimeMs && cached.size === stats.size) return cached.specifiers;
|
|
563
|
-
const specifiers = this.#scanImports(file, await Bun.file(file).text());
|
|
564
|
-
this.#scanCache.set(file, { mtimeMs, size: stats.size, specifiers });
|
|
565
|
-
return specifiers;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
async #entrypoints(): Promise<string[]> {
|
|
569
|
-
const roots = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
570
|
-
const existing: string[] = [];
|
|
571
|
-
for (const root of roots) {
|
|
572
|
-
const abs = path.resolve(root);
|
|
573
|
-
if (await Bun.file(abs).exists()) existing.push(abs);
|
|
574
|
-
}
|
|
575
|
-
return existing;
|
|
576
|
-
}
|
|
577
|
-
|
|
578
|
-
#resolve(specifier: string, importerDir: string): string | null {
|
|
579
|
-
try {
|
|
580
|
-
const resolved = Bun.resolveSync(specifier, importerDir);
|
|
581
|
-
if (!path.isAbsolute(resolved)) return null;
|
|
582
|
-
if (!SOURCE_EXTS.has(path.extname(resolved).toLowerCase())) return null;
|
|
583
|
-
return path.resolve(resolved);
|
|
584
|
-
} catch {
|
|
585
|
-
return null;
|
|
586
|
-
}
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
#isWorkspaceSource(file: string, workspaceRoot: string): boolean {
|
|
590
|
-
const rel = path.relative(workspaceRoot, file);
|
|
591
|
-
if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
592
|
-
if (rel.includes(`${path.sep}node_modules${path.sep}`) || rel.includes(`${path.sep}.akan${path.sep}`)) return false;
|
|
593
|
-
return SOURCE_EXTS.has(path.extname(file).toLowerCase());
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
#scanImports(file: string, source: string): Bun.Import[] {
|
|
597
|
-
const ext = path.extname(file).toLowerCase();
|
|
598
|
-
if (ext === ".tsx") return this.#tsxTranspiler.scanImports(source);
|
|
599
|
-
if (ext === ".jsx") return this.#jsxTranspiler.scanImports(source);
|
|
600
|
-
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return this.#jsTranspiler.scanImports(source);
|
|
601
|
-
return this.#tsTranspiler.scanImports(source);
|
|
602
|
-
}
|
|
603
|
-
}
|
|
604
|
-
|
|
605
93
|
export class AkanAppHost {
|
|
606
94
|
logger = new Logger("AkanAppHost");
|
|
607
|
-
readonly
|
|
95
|
+
readonly stdio: DevStdioMode;
|
|
608
96
|
readonly env: Record<string, string>;
|
|
609
|
-
#
|
|
97
|
+
readonly #onDevEvent: ((event: DevHostEvent) => void) | null;
|
|
98
|
+
#lastDevState: DevHostState | null = null;
|
|
99
|
+
#backend: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe"> | null = null;
|
|
610
100
|
#builder: IncrementalBuilderHost | null = null;
|
|
611
101
|
#backendReady = false;
|
|
612
102
|
#plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
|
|
@@ -649,12 +139,22 @@ export class AkanAppHost {
|
|
|
649
139
|
readonly #builderRequests = new BuilderRequestRouter();
|
|
650
140
|
constructor(
|
|
651
141
|
private readonly app: App,
|
|
652
|
-
{
|
|
142
|
+
{
|
|
143
|
+
env,
|
|
144
|
+
stdio = "inherit",
|
|
145
|
+
onDevEvent,
|
|
146
|
+
}: { env: Record<string, string>; stdio?: DevStdioMode; onDevEvent?: (event: DevHostEvent) => void },
|
|
653
147
|
) {
|
|
654
148
|
this.env = env;
|
|
655
|
-
this.
|
|
149
|
+
this.stdio = stdio;
|
|
150
|
+
this.#onDevEvent = onDevEvent ?? null;
|
|
656
151
|
this.#backendGraph = new BackendImportGraph(app, this.logger);
|
|
657
152
|
}
|
|
153
|
+
#emitDevEvent(state: DevHostState, detail?: string) {
|
|
154
|
+
if (!this.#onDevEvent || state === this.#lastDevState) return;
|
|
155
|
+
this.#lastDevState = state;
|
|
156
|
+
this.#onDevEvent({ app: this.app.name, state, ...(detail ? { detail } : {}) });
|
|
157
|
+
}
|
|
658
158
|
async start() {
|
|
659
159
|
if (this.#backend) await this.#stopBackend();
|
|
660
160
|
if (this.#builder) this.#stopBuilder();
|
|
@@ -702,6 +202,7 @@ export class AkanAppHost {
|
|
|
702
202
|
// Before the spawn: from here on, a builder answer for the departing backend must not be delivered to
|
|
703
203
|
// this one, which numbers its requests from 1 all over again.
|
|
704
204
|
this.#builderRequests.startGeneration();
|
|
205
|
+
this.#discardPendingBuilderMessages("the backend restarted while the builder was away");
|
|
705
206
|
this.#backendStartStatus = startStatus;
|
|
706
207
|
this.#backendGaveUp = false;
|
|
707
208
|
this.#setBackendLifecycleState("starting");
|
|
@@ -709,7 +210,7 @@ export class AkanAppHost {
|
|
|
709
210
|
this.#backendStderrTail = [];
|
|
710
211
|
const backend = Bun.spawn(["bun", `apps/${this.app.name}/main.ts`], {
|
|
711
212
|
cwd: this.app.workspace.workspaceRoot,
|
|
712
|
-
stdio: this.
|
|
213
|
+
stdio: this.stdio === "pipe" ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
|
|
713
214
|
env: this.env,
|
|
714
215
|
ipc: (msg: BuilderMessage) => {
|
|
715
216
|
if (!msg || typeof msg !== "object") return;
|
|
@@ -749,9 +250,9 @@ export class AkanAppHost {
|
|
|
749
250
|
});
|
|
750
251
|
this.#backend = backend;
|
|
751
252
|
this.logger.verbose(`backend spawned pid=${backend.pid}`);
|
|
752
|
-
if (this.
|
|
753
|
-
//
|
|
754
|
-
//
|
|
253
|
+
if (this.stdio === "pipe") {
|
|
254
|
+
// A piped backend writes to nobody unless the pipes are drained; surface them through the logger
|
|
255
|
+
// so a runtime error is not silently swallowed, and so the tail kept for a boot failure still fills.
|
|
755
256
|
void this.#forwardBackendStream(backend.stderr as unknown as ReadableStream<Uint8Array> | undefined, "stderr");
|
|
756
257
|
void this.#forwardBackendStream(backend.stdout as unknown as ReadableStream<Uint8Array> | undefined, "stdout");
|
|
757
258
|
}
|
|
@@ -764,19 +265,23 @@ export class AkanAppHost {
|
|
|
764
265
|
this.#backendStderrTail.splice(0, this.#backendStderrTail.length - BACKEND_STDERR_TAIL_LIMIT);
|
|
765
266
|
}
|
|
766
267
|
}
|
|
268
|
+
/**
|
|
269
|
+
* A piped child is written through verbatim rather than re-logged: a level floor here would swallow
|
|
270
|
+
* what the runtime printed, and re-rendering would double the timestamp the child already wrote. So
|
|
271
|
+
* `"pipe"` looks exactly like `"inherit"` to whoever owns this process's stdout — the only difference
|
|
272
|
+
* is `#backendStderrTail`, which is what the crash-loop diagnostic reads.
|
|
273
|
+
*/
|
|
767
274
|
async #forwardBackendStream(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
|
|
768
275
|
if (!stream) return;
|
|
769
276
|
const decoder = new TextDecoder();
|
|
770
277
|
try {
|
|
771
278
|
for await (const chunk of stream) {
|
|
772
279
|
const text = decoder.decode(chunk, { stream: true });
|
|
773
|
-
if (!text
|
|
280
|
+
if (!text) continue;
|
|
774
281
|
if (kind === "stderr") {
|
|
775
282
|
this.#recordBackendStderr(text);
|
|
776
|
-
|
|
777
|
-
} else
|
|
778
|
-
this.logger.verbose(`[backend] ${text.trimEnd()}`);
|
|
779
|
-
}
|
|
283
|
+
process.stderr.write(text);
|
|
284
|
+
} else process.stdout.write(text);
|
|
780
285
|
}
|
|
781
286
|
} catch {
|
|
782
287
|
// The stream closes when the backend exits; nothing further to surface here.
|
|
@@ -829,6 +334,7 @@ export class AkanAppHost {
|
|
|
829
334
|
const prev = this.#backendLifecycleState;
|
|
830
335
|
this.#backendLifecycleState = next;
|
|
831
336
|
this.logger.verbose(`[backend-lifecycle] ${prev} -> ${next}${detail ? ` ${detail}` : ""}`);
|
|
337
|
+
this.#emitDevEvent(devHostStateOf(next, this.#backendGaveUp), detail);
|
|
832
338
|
}
|
|
833
339
|
#sendToBackend(message: BuilderMessage) {
|
|
834
340
|
if (!this.#backend || !this.#backendReady) {
|
|
@@ -1209,6 +715,7 @@ export class AkanAppHost {
|
|
|
1209
715
|
return;
|
|
1210
716
|
}
|
|
1211
717
|
this.#suspended = true;
|
|
718
|
+
this.#emitDevEvent("suspended", `idle ${Math.round(idleMs / 1000)}s`);
|
|
1212
719
|
this.#stopBuilder();
|
|
1213
720
|
this.#openBuilderGap("idle suspend");
|
|
1214
721
|
this.logger.info(
|
|
@@ -1293,6 +800,9 @@ export class AkanAppHost {
|
|
|
1293
800
|
this.#wokeAtMono = performance.now();
|
|
1294
801
|
this.#flushPendingBuilderMessages();
|
|
1295
802
|
this.#armIdleSuspend();
|
|
803
|
+
// A wake often leaves the backend's own state untouched, so nothing else would report that the
|
|
804
|
+
// app came back and a supervisor would show it suspended for the rest of the session.
|
|
805
|
+
this.#emitDevEvent(devHostStateOf(this.#backendLifecycleState, this.#backendGaveUp));
|
|
1296
806
|
}
|
|
1297
807
|
}
|
|
1298
808
|
async #applyIdleWake(batch: ChangeBatch | null): Promise<void> {
|
|
@@ -1412,6 +922,23 @@ export class AkanAppHost {
|
|
|
1412
922
|
);
|
|
1413
923
|
}
|
|
1414
924
|
|
|
925
|
+
/**
|
|
926
|
+
* Drop everything held for a backend generation that has been replaced, answering nobody.
|
|
927
|
+
*
|
|
928
|
+
* A held request carries the id the *departing* backend gave it, and `startGeneration` only clears the
|
|
929
|
+
* router — so a replay after the restart would issue that id under the new generation and settle the
|
|
930
|
+
* builder's answer to an id the new backend is itself handing out from 1. That is exactly the
|
|
931
|
+
* misdelivery `BuilderRequestRouter` exists to prevent, reached through the hold queue instead: the
|
|
932
|
+
* new backend gets another route's manifest delta, and its real answer is dropped as unclaimed.
|
|
933
|
+
*
|
|
934
|
+
* Nothing is answered because every caller of `#startBackend` has already stopped the old backend, so
|
|
935
|
+
* a failure reply would only be dropped as undeliverable.
|
|
936
|
+
*/
|
|
937
|
+
#discardPendingBuilderMessages(reason: string): void {
|
|
938
|
+
const held = this.#pendingBuilderMessages.splice(0);
|
|
939
|
+
if (held.length === 0) return;
|
|
940
|
+
this.logger.verbose(`[builder] dropped ${held.length} held builder request(s): ${reason}`);
|
|
941
|
+
}
|
|
1415
942
|
/**
|
|
1416
943
|
* Answer everything still waiting on a builder that is not coming back. Held requests are otherwise
|
|
1417
944
|
* invisible to the backend, which would sit on them until its own timeout with no reason given.
|
|
@@ -1718,9 +1245,17 @@ export class AkanAppHost {
|
|
|
1718
1245
|
this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
|
|
1719
1246
|
let lastError: unknown;
|
|
1720
1247
|
for (let attempt = 1; attempt <= BUILDER_START_MAX_ATTEMPTS; attempt++) {
|
|
1721
|
-
this.#builder = await IncrementalBuilderHost.create(
|
|
1722
|
-
this
|
|
1723
|
-
|
|
1248
|
+
this.#builder = await IncrementalBuilderHost.create(
|
|
1249
|
+
this.app,
|
|
1250
|
+
this.env,
|
|
1251
|
+
(msg) => {
|
|
1252
|
+
this.#enqueueBuilderMessage(msg);
|
|
1253
|
+
},
|
|
1254
|
+
{
|
|
1255
|
+
stdio: this.stdio,
|
|
1256
|
+
onOutput: (kind, text) => void (kind === "stderr" ? process.stderr : process.stdout).write(text),
|
|
1257
|
+
},
|
|
1258
|
+
);
|
|
1724
1259
|
try {
|
|
1725
1260
|
await this.#waitForBuilderReady(attempt, { announceBootState });
|
|
1726
1261
|
this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms — starting backend`);
|