@akanjs/devkit 3.0.0-beta.9 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/DEV_RUNTIME_KNOBS.md +44 -26
- package/README.ko.md +21 -7
- package/README.md +17 -8
- package/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +6 -8
- package/agentsIndex.test.ts +4 -7
- package/agentsIndex.ts +6 -22
- package/akanApp/BackendImportGraph.test.ts +8 -22
- package/akanApp/BackendImportGraph.ts +1 -20
- package/akanApp/akanApp.host.test.ts +56 -3
- package/akanApp/akanApp.host.ts +218 -278
- package/akanApp/devBootLatch.test.ts +39 -0
- package/akanApp/devBootLatch.ts +39 -0
- package/akanApp/devHostPolicy.test.ts +81 -35
- package/akanApp/devHostPolicy.ts +41 -102
- package/akanConfig/akanBin.test.ts +62 -0
- package/akanConfig/akanBin.ts +78 -0
- package/akanConfig/akanConfig.test.ts +439 -141
- package/akanConfig/akanConfig.ts +359 -275
- package/akanConfig/index.ts +1 -0
- package/akanConfig/types.ts +22 -8
- package/akanContext.ts +110 -160
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.test.ts +41 -5
- package/applicationBuildRunner.ts +71 -63
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +32 -30
- package/artifact/implicitRootLayout.ts +58 -106
- package/artifact/routeSeedIndex.test.ts +7 -6
- package/artifact/routeSeedIndex.ts +11 -44
- package/biome.base.json +35 -2
- package/biomeBase.ts +2 -5
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +77 -39
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +26 -5
- package/cloud/globalConfig.ts +71 -32
- package/codeAgent/agent/AkanCodeServices.ts +97 -0
- package/codeAgent/agent/AkanContextFiles.ts +94 -0
- package/codeAgent/agent/CodeAgent.ts +551 -0
- package/codeAgent/agent/CodeAgentAsks.ts +66 -0
- package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
- package/codeAgent/agent/CodeAgentGate.ts +93 -0
- package/codeAgent/agent/CodeAgentProxy.ts +82 -0
- package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
- package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
- package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
- package/codeAgent/agent/CodeAgentUi.ts +47 -0
- package/codeAgent/agent/CodeMailbox.ts +148 -0
- package/codeAgent/agent/CodeSessionFork.ts +56 -0
- package/codeAgent/agent/CodeSessionIndex.ts +78 -0
- package/codeAgent/agent/SubagentPool.ts +214 -0
- package/codeAgent/agent/akanCodeModel.ts +47 -0
- package/codeAgent/agent/akanCodePaths.ts +31 -0
- package/codeAgent/agent/akanContextFiles.test.ts +114 -0
- package/codeAgent/agent/akanSystemPrompt.ts +46 -0
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +523 -0
- package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
- package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
- package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
- package/codeAgent/agent/codeMailbox.test.ts +76 -0
- package/codeAgent/agent/codeSessionFork.test.ts +58 -0
- package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
- package/codeAgent/agent/consoleToStderr.ts +8 -0
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +82 -0
- package/codeAgent/feedback/PreviewChrome.ts +82 -0
- package/codeAgent/feedback/PreviewFeedback.ts +46 -0
- package/codeAgent/feedback/PreviewView.ts +95 -0
- package/codeAgent/feedback/TurnFeedback.ts +98 -0
- package/codeAgent/feedback/VerifyFeedback.ts +40 -0
- package/codeAgent/feedback/previewChrome.test.ts +73 -0
- package/codeAgent/index.ts +34 -0
- package/codeAgent/skills/akan-code/SKILL.md +130 -0
- package/codeAgent/skills/akan-debug/SKILL.md +42 -0
- package/codeAgent/skills/akan-deps/SKILL.md +50 -0
- package/codeAgent/skills/akan-err/SKILL.md +39 -0
- package/codeAgent/skills/akan-module/SKILL.md +45 -0
- package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
- package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
- package/codeAgent/skills/akan-service/SKILL.md +34 -0
- package/codeAgent/skills/akan-store/SKILL.md +67 -0
- package/codeAgent/skills/akan-ui/SKILL.md +63 -0
- package/codeAgent/skills/akan-validate/SKILL.md +44 -0
- package/codeAgent/tools/AkanCli.ts +50 -0
- package/codeAgent/tools/AkanCodePlugins.ts +136 -0
- package/codeAgent/tools/AkanEditScope.ts +48 -0
- package/codeAgent/tools/AkanEnvKeys.ts +50 -0
- package/codeAgent/tools/AkanToolPack.ts +92 -0
- package/codeAgent/tools/AkanVerifier.ts +48 -0
- package/codeAgent/tools/AskToolPack.ts +99 -0
- package/codeAgent/tools/MailToolPack.ts +76 -0
- package/codeAgent/tools/McpClient.ts +187 -0
- package/codeAgent/tools/McpOAuth.ts +233 -0
- package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
- package/codeAgent/tools/McpServerConfig.ts +129 -0
- package/codeAgent/tools/McpSignIn.ts +158 -0
- package/codeAgent/tools/McpTokenStore.ts +81 -0
- package/codeAgent/tools/McpToolPack.ts +199 -0
- package/codeAgent/tools/SessionToolPack.ts +91 -0
- package/codeAgent/tools/WebToolPack.ts +147 -0
- package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
- package/codeAgent/tools/askToolPack.test.ts +116 -0
- package/codeAgent/tools/mcpOAuth.test.ts +612 -0
- package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
- package/codegenLock.test.ts +4 -13
- package/codegenLock.ts +3 -17
- package/commandDecorators/argMeta.ts +1 -5
- package/commandDecorators/camelToKebabCase.ts +1 -0
- package/commandDecorators/command.ts +46 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +2 -10
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +68 -145
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/csrE2e/csrE2eHarness.fixture.ts +505 -0
- package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
- package/csrE2e/csrPageStack.e2e.test.ts +252 -0
- package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
- package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
- package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
- package/csrE2e/registryVendor.e2e.test.ts +221 -0
- package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +31 -80
- package/devkitUtils.test.ts +48 -20
- package/executors.linkFallback.test.ts +103 -0
- package/executors.test.ts +176 -106
- package/executors.ts +212 -498
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +5 -3
- package/fileSys.ts +22 -9
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +27 -38
- package/frontendBuild/autoImportSync.ts +31 -65
- package/frontendBuild/buildRouteClient.test.ts +123 -37
- package/frontendBuild/bundleDefine.test.ts +12 -0
- package/frontendBuild/bundleDefine.ts +16 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
- package/frontendBuild/clientEntryDiscovery.ts +122 -110
- package/frontendBuild/csrArtifactBuilder.ts +9 -31
- package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
- package/frontendBuild/csrDevArtifactWriter.ts +307 -0
- package/frontendBuild/csrDevBundler.test.ts +66 -0
- package/frontendBuild/csrDevBundler.ts +95 -0
- package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
- package/frontendBuild/csrDevFactoryCheck.ts +39 -0
- package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
- package/frontendBuild/csrDevModuleCompiler.ts +422 -0
- package/frontendBuild/csrDevPatcher.test.ts +572 -0
- package/frontendBuild/csrDevPatcher.ts +548 -0
- package/frontendBuild/csrDevPaths.ts +145 -0
- package/frontendBuild/csrDevResolver.test.ts +239 -0
- package/frontendBuild/csrDevResolver.ts +363 -0
- package/frontendBuild/csrDevTypes.ts +61 -0
- package/frontendBuild/csrEntryFiles.ts +24 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +31 -82
- package/frontendBuild/cssImportResolver.ts +6 -19
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/devRegistryBundler.ts +192 -0
- package/frontendBuild/fontOptimizer.test.ts +6 -14
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +57 -70
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +59 -32
- package/frontendBuild/hmrWatcher.ts +31 -74
- package/frontendBuild/index.ts +4 -0
- package/frontendBuild/pagesBundleBuilder.ts +28 -30
- package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
- package/frontendBuild/registryMetadataFingerprint.ts +28 -0
- package/frontendBuild/routeClientBuilder.ts +100 -62
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/serverGraphFile.test.ts +77 -0
- package/frontendBuild/serverGraphFile.ts +93 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
- package/frontendBuild/ssrDevBundler.ts +85 -0
- package/frontendBuild/styleContract.ts +1 -8
- package/frontendBuild/themeValidator.ts +4 -26
- package/frontendBuild/watchRootResolver.test.ts +23 -42
- package/frontendBuild/watchRootResolver.ts +5 -27
- package/getRelatedCnsts.ts +12 -92
- package/incrementalBuilder/buildBatch.proc.ts +147 -63
- package/incrementalBuilder/buildBatchProtocol.ts +13 -38
- package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
- package/incrementalBuilder/buildBatchRunner.ts +26 -38
- package/incrementalBuilder/builderChannel.test.ts +24 -49
- package/incrementalBuilder/builderChannel.ts +6 -37
- package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
- package/incrementalBuilder/builderRequestRouter.ts +5 -24
- package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
- package/incrementalBuilder/builderWorkQueue.ts +126 -0
- package/incrementalBuilder/devWatchBatch.test.ts +21 -24
- package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
- package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
- package/index.ts +5 -19
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +105 -199
- package/integration/devStabilityHarness.test.ts +13 -28
- package/integration/devStabilityHarness.ts +65 -330
- package/integration/ssrMemoryProbe.ts +41 -167
- package/libSource.test.ts +4 -15
- package/libSource.ts +19 -31
- package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
- package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
- package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
- package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
- package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
- package/lint/gritRules.test.ts +8 -24
- package/lint/no-deep-internal-import.grit +2 -2
- package/lint/no-document-cookie.grit +29 -0
- package/lint/no-redeclare-predefined-endpoint.grit +2 -1
- package/lint/no-web-only-api-outside-webkit.grit +44 -0
- package/lint/no-web-storage.grit +30 -0
- package/linter.test.ts +1 -2
- package/linter.ts +26 -116
- package/mobile/desktopBin.test.ts +182 -0
- package/mobile/desktopBin.ts +144 -0
- package/mobile/desktopServerStage.test.ts +252 -0
- package/mobile/desktopServerStage.ts +178 -0
- package/mobile/index.ts +9 -0
- package/mobile/mobileTarget.test.ts +57 -8
- package/mobile/mobileTarget.ts +45 -26
- package/mobile/nativeApi.test.ts +30 -0
- package/mobile/nativeApi.ts +40 -0
- package/mobile/nativeApp.test.ts +184 -0
- package/mobile/nativeApp.ts +454 -0
- package/mobile/nativeAppLine.test.ts +69 -0
- package/mobile/nativeAppLine.ts +71 -0
- package/mobile/nativeConfig.test.ts +438 -0
- package/mobile/nativeConfig.ts +294 -0
- package/mobile/nativePluginFolders.test.ts +81 -0
- package/mobile/nativePluginFolders.ts +60 -0
- package/mobile/nativeWebDir.test.ts +80 -0
- package/mobile/nativeWebDir.ts +66 -0
- package/mobile/placeholderAppId.test.ts +13 -0
- package/mobile/placeholderAppId.ts +16 -0
- package/mobile/usageDescriptions.test.ts +27 -0
- package/mobile/usageDescriptions.ts +24 -0
- package/package.json +11 -11
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +46 -0
- package/platformTest/BunTestReport.test.ts +60 -0
- package/platformTest/BunTestReport.ts +65 -0
- package/platformTest/IdleWatchedProcess.test.ts +56 -0
- package/platformTest/IdleWatchedProcess.ts +73 -0
- package/platformTest/LinuxDockerTarget.ts +105 -0
- package/platformTest/PlatformTestRun.test.ts +64 -0
- package/platformTest/PlatformTestRun.ts +141 -0
- package/platformTest/PlatformTestTarget.ts +142 -0
- package/platformTest/TestSnapshot.test.ts +72 -0
- package/platformTest/TestSnapshot.ts +103 -0
- package/platformTest/WindowsSshTarget.ts +151 -0
- package/prompter.ts +3 -13
- package/qualityScanner.test.ts +9 -26
- package/qualityScanner.ts +200 -286
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.test.ts +0 -1
- package/routeSourceValidator.ts +7 -42
- package/scanInfo.ts +91 -225
- package/selectModel.ts +1 -2
- package/semver.ts +3 -5
- package/slicePlanner.test.ts +6 -24
- package/slicePlanner.ts +11 -32
- package/spinner.test.ts +3 -8
- package/spinner.ts +4 -16
- package/sshTunnel.ts +1 -6
- package/ssrScanner.test.ts +1 -6
- package/ssrScanner.ts +103 -113
- package/stripAnsi.ts +3 -0
- package/subspace.test.ts +9 -25
- package/subspace.ts +48 -115
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +194 -0
- package/transforms/asyncDefaultExportDetector.ts +3 -11
- package/transforms/barrelAnalyzer.ts +43 -135
- package/transforms/barrelImportsPlugin.ts +38 -175
- package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
- package/transforms/externalizeFrameworkPlugin.ts +13 -87
- package/transforms/moduleSyntax.ts +32 -0
- package/transforms/rscUseClientTransform.ts +42 -20
- package/transforms/serverEnvPlugin.test.ts +103 -0
- package/transforms/serverEnvPlugin.ts +41 -0
- package/transforms/transforms.test.ts +166 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +6 -23
- package/typeChecker.ts +7 -63
- package/types.ts +2 -0
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +101 -110
- package/workflow/index.ts +0 -1
- package/workflow/moduleIndex.test.ts +4 -15
- package/workflow/moduleIndex.ts +9 -49
- package/workflow/plan.ts +18 -25
- package/workflow/render.ts +80 -96
- package/workflow/rolloutGate.test.ts +1 -1
- package/workflow/source.ts +52 -69
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.test.ts +18 -3
- package/workspaceLayout.ts +22 -23
- package/abstractCompactor.ts +0 -112
- package/aiEditor.test.ts +0 -60
- package/aiEditor.ts +0 -387
- package/applicationReleasePackager.ts +0 -209
- package/capacitorApp.test.ts +0 -372
- package/capacitorApp.ts +0 -1648
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/guideline.ts +0 -19
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -1,19 +1,22 @@
|
|
|
1
|
+
import { rm } from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
|
-
// Module paths, never a barrel
|
|
3
|
-
//
|
|
4
|
-
|
|
5
|
-
// (~40MB) into a process that then holds them for the whole dev session. Phase 2 moved css compilation
|
|
6
|
-
// into the batch worker, so this process has no use for them — `entryModuleGraph.test.ts` keeps it that way.
|
|
3
|
+
// Module paths, never a barrel: the devkit and `frontendBuild` barrels would hold tailwindcss (~40MB), ssh2, ink
|
|
4
|
+
// and the cloud stack for the whole dev session (`entryModuleGraph.test.ts` enforces it).
|
|
5
|
+
import { ApplicationBuildReporter } from "@akanjs/devkit/applicationBuildReporter";
|
|
7
6
|
import { CodegenLock } from "@akanjs/devkit/codegenLock";
|
|
8
7
|
import type { App } from "@akanjs/devkit/commandDecorators";
|
|
9
8
|
import { AppExecutor, type PageRoot, WorkspaceExecutor } from "@akanjs/devkit/executors";
|
|
10
9
|
import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
|
|
11
10
|
import type { ClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientBuildTypes";
|
|
12
11
|
import { GraphClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientEntryDiscovery";
|
|
12
|
+
import { CsrDevBundler } from "@akanjs/devkit/frontendBuild/csrDevBundler";
|
|
13
|
+
import { CsrDevPatcher } from "@akanjs/devkit/frontendBuild/csrDevPatcher";
|
|
13
14
|
import { DevChangePlanner } from "@akanjs/devkit/frontendBuild/devChangePlanner";
|
|
14
15
|
import { DevGeneratedIndexSync } from "@akanjs/devkit/frontendBuild/devGeneratedIndexSync";
|
|
15
16
|
import { HmrWatcher } from "@akanjs/devkit/frontendBuild/hmrWatcher";
|
|
16
17
|
import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuilder";
|
|
18
|
+
import { ServerGraphFile } from "@akanjs/devkit/frontendBuild/serverGraphFile";
|
|
19
|
+
import { SsrDevBundler } from "@akanjs/devkit/frontendBuild/ssrDevBundler";
|
|
17
20
|
import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
|
|
18
21
|
import { Logger } from "akanjs/common";
|
|
19
22
|
import type {
|
|
@@ -25,10 +28,13 @@ import type {
|
|
|
25
28
|
BuildPhase,
|
|
26
29
|
BuildRouteResultPayload,
|
|
27
30
|
ChangeBatch,
|
|
31
|
+
HmrTrace,
|
|
28
32
|
} from "akanjs/server";
|
|
33
|
+
import { CSR_DEV_PATCHING_MARKER } from "akanjs/server/hmr/csrDevManifest";
|
|
29
34
|
import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
|
|
30
35
|
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
31
36
|
import { BuilderChannel } from "./builderChannel";
|
|
37
|
+
import { type BatchJob, BuilderWorkQueue, type DiscoveryJob } from "./builderWorkQueue";
|
|
32
38
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
33
39
|
|
|
34
40
|
interface IncrementalBuilderOptions {
|
|
@@ -41,6 +47,7 @@ interface IncrementalBuilderOptions {
|
|
|
41
47
|
}
|
|
42
48
|
|
|
43
49
|
type IncrementalBuilderBootDeps = Pick<IncrementalBuilderOptions, "artifact" | "optimizedFonts" | "discovery">;
|
|
50
|
+
type BatchWork = Pick<BuildBatchRequest, "generation" | "needs" | "changedFiles" | "trace">;
|
|
44
51
|
|
|
45
52
|
class IncrementalBuilder {
|
|
46
53
|
#logger = new Logger("IncrementalBuilder");
|
|
@@ -56,8 +63,43 @@ class IncrementalBuilder {
|
|
|
56
63
|
#autoImportSync: AutoImportSync;
|
|
57
64
|
#watcher: HmrWatcher | null = null;
|
|
58
65
|
#generation = 0;
|
|
66
|
+
#discoveredThrough = 0;
|
|
59
67
|
#csrActive = IncrementalBuilder.#csrArmedByEnv();
|
|
60
|
-
#
|
|
68
|
+
#csrBundler: CsrDevBundler;
|
|
69
|
+
/** Null when `AKAN_DEV_CSR_PATCHER=off`: every CSR save then goes to a build worker, as before the patcher. */
|
|
70
|
+
#patcher: CsrDevPatcher | null;
|
|
71
|
+
/** A worker's full CSR build (arming, re-arming) that a patch must not race for the csr-dev directory. */
|
|
72
|
+
#csrGate: Promise<void> = Promise.resolve();
|
|
73
|
+
#csrArming = false;
|
|
74
|
+
#csrArms = 0;
|
|
75
|
+
#crashRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
76
|
+
readonly #crashRetryEntries = new Set<string>();
|
|
77
|
+
#csrWasActive = false;
|
|
78
|
+
#csrArmSucceeded = false;
|
|
79
|
+
/** Where SSR pages load their client code from in dev; route builds bundle only its server half. */
|
|
80
|
+
#ssrBundler: SsrDevBundler;
|
|
81
|
+
/** Null when `AKAN_DEV_CSR_PATCHER=off`: every registry save then goes to a build worker. */
|
|
82
|
+
#ssrPatcher: CsrDevPatcher | null;
|
|
83
|
+
//* Serializes everything that writes ssr-dev: a save's patch (fast lane), a route build adding the entries it names,
|
|
84
|
+
//* a queued worker batch with the ssr need, and the boot build. Never held while awaiting the slow lane.
|
|
85
|
+
#ssrLock: Promise<void> = Promise.resolve();
|
|
86
|
+
/** How often each reason sent a registry save to a build worker this session: the resident patcher's miss rate. */
|
|
87
|
+
readonly #delegations = new Map<string, number>();
|
|
88
|
+
/** The boot build of the SSR registry while it runs; it holds every entry the routes reach once it lands. */
|
|
89
|
+
#ssrArming: Promise<void> | null = null;
|
|
90
|
+
//* A registry build that failed on the user's code fails again until the code changes, so route builds do not retry
|
|
91
|
+
//* it; the next attempt that touches the registry (a save's patch, a worker batch) settles it. A worker that died
|
|
92
|
+
//* before reporting (an OOM kill) route builds do retry, at most every `#crashRetryMs`.
|
|
93
|
+
#ssrBroken = false;
|
|
94
|
+
#ssrCrashedAt: number | null = null;
|
|
95
|
+
static readonly #crashRetryMs = 10_000;
|
|
96
|
+
/** Arming and the route entries deferred behind it run beside both lanes; a recycle waits for them too. */
|
|
97
|
+
readonly #beside = new Set<Promise<void>>();
|
|
98
|
+
#barrelFailed = false;
|
|
99
|
+
//* Two lanes: a save's codegen and CSR patch in the fast one, which the watcher waits for; build workers, route
|
|
100
|
+
//* builds and discovery in the slow one, which folds queued batches. A save no longer waits behind pages and css.
|
|
101
|
+
#fastQueue: Promise<void> = Promise.resolve();
|
|
102
|
+
#workQueue: BuilderWorkQueue;
|
|
61
103
|
#inFlight = 0;
|
|
62
104
|
#workCount = 0;
|
|
63
105
|
#shuttingDown = false;
|
|
@@ -71,6 +113,7 @@ class IncrementalBuilder {
|
|
|
71
113
|
this.#optimizedFonts = options.optimizedFonts;
|
|
72
114
|
this.#discovery = options.discovery;
|
|
73
115
|
this.#generation = options.initialGeneration ?? 0;
|
|
116
|
+
this.#discoveredThrough = this.#generation;
|
|
74
117
|
this.#batchRunner = new BuildBatchRunner({
|
|
75
118
|
workspaceRoot: options.app.workspace.workspaceRoot,
|
|
76
119
|
cwd: options.app.cwdPath,
|
|
@@ -78,34 +121,51 @@ class IncrementalBuilder {
|
|
|
78
121
|
this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
79
122
|
this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
80
123
|
this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
|
|
124
|
+
this.#csrBundler = new CsrDevBundler(options.app);
|
|
125
|
+
this.#patcher =
|
|
126
|
+
process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#csrBundler, { resident: true });
|
|
127
|
+
this.#ssrBundler = new SsrDevBundler(options.app, { discovery: () => this.#discovery });
|
|
128
|
+
this.#ssrPatcher =
|
|
129
|
+
process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#ssrBundler, { resident: true });
|
|
130
|
+
this.#workQueue = new BuilderWorkQueue({
|
|
131
|
+
runBatch: async (batch) => await this.#runQueuedBatch(batch),
|
|
132
|
+
onSettled: (label, ms) => {
|
|
133
|
+
this.#workCount += 1;
|
|
134
|
+
this.#logger.verbose(`[work-queue] ${label} finished in ${ms}ms`);
|
|
135
|
+
this.#reportMetrics();
|
|
136
|
+
},
|
|
137
|
+
});
|
|
81
138
|
}
|
|
82
139
|
|
|
83
140
|
get #artifactDir() {
|
|
84
141
|
return `${this.#app.cwdPath}/.akan/artifact`;
|
|
85
142
|
}
|
|
86
143
|
|
|
87
|
-
|
|
88
|
-
* Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
|
|
89
|
-
* work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
|
|
90
|
-
*/
|
|
144
|
+
// The reply is part of the work item on purpose: `shutdown` drains the queue, so "drained" must mean "answered".
|
|
91
145
|
async handleBuildRoute(msg: BuilderReq): Promise<void> {
|
|
92
|
-
await this.#
|
|
146
|
+
await this.#workQueue.enqueue(`build-route:${msg.routeId}`, async () =>
|
|
93
147
|
BuilderChannel.send(await this.#handleBuildRoute(msg)),
|
|
94
148
|
);
|
|
95
149
|
}
|
|
96
150
|
|
|
97
151
|
async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
|
|
152
|
+
const seenGeneration = this.#discoveredThrough;
|
|
98
153
|
try {
|
|
99
154
|
const delta = await new RouteClientBuilder({
|
|
100
155
|
app: this.#app,
|
|
101
156
|
routeId: msg.routeId,
|
|
102
157
|
seeds: msg.seeds,
|
|
158
|
+
graphSeeds: msg.graphSeeds,
|
|
103
159
|
artifact: this.#artifact,
|
|
104
160
|
knownEntries: new Set<string>(msg.knownEntries),
|
|
105
161
|
discovery: this.#discovery,
|
|
162
|
+
browser: "registry",
|
|
106
163
|
}).build();
|
|
164
|
+
await this.#ensureSsrEntries(delta.registryEntries ?? []);
|
|
107
165
|
this.#logger.verbose(`build-route ok routeId=${msg.routeId} newEntries=${delta.newEntries.length}`);
|
|
108
|
-
|
|
166
|
+
//? The builder's generation, not the route cache's: the cache's does not move when a fixed file maps to no entry,
|
|
167
|
+
//? so the rebuild's ok would carry the failure's own number.
|
|
168
|
+
this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds, scope: msg.routeId });
|
|
109
169
|
return {
|
|
110
170
|
type: "build-route-res",
|
|
111
171
|
id: msg.id,
|
|
@@ -119,56 +179,70 @@ class IncrementalBuilder {
|
|
|
119
179
|
clientDepsByEntry: delta.clientDepsByEntry,
|
|
120
180
|
routeId: msg.routeId,
|
|
121
181
|
generation: msg.generation,
|
|
182
|
+
seenGeneration,
|
|
122
183
|
} as BuildRouteResultPayload,
|
|
123
184
|
};
|
|
124
185
|
} catch (err) {
|
|
125
|
-
const errMsg = err
|
|
186
|
+
const errMsg = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
126
187
|
this.#logger.error(`build-route failed routeId=${msg.routeId}: ${errMsg}`);
|
|
127
|
-
this.#sendBuildStatus("route", {
|
|
188
|
+
this.#sendBuildStatus("route", {
|
|
189
|
+
generation: this.#generation,
|
|
190
|
+
ok: false,
|
|
191
|
+
files: msg.seeds,
|
|
192
|
+
message: errMsg,
|
|
193
|
+
scope: msg.routeId,
|
|
194
|
+
});
|
|
128
195
|
return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
|
|
129
196
|
}
|
|
130
197
|
}
|
|
131
198
|
#sendBuildStatus(
|
|
132
199
|
phase: BuildPhase,
|
|
133
|
-
{
|
|
200
|
+
{
|
|
201
|
+
generation,
|
|
202
|
+
ok,
|
|
203
|
+
files,
|
|
204
|
+
message,
|
|
205
|
+
scope,
|
|
206
|
+
}: { generation?: number; ok: boolean; files?: string[]; message?: string; scope?: string },
|
|
134
207
|
): void {
|
|
135
208
|
if (typeof generation !== "number") return;
|
|
136
209
|
BuilderChannel.emit({
|
|
137
210
|
type: "build-status",
|
|
138
|
-
data: {
|
|
139
|
-
generation,
|
|
140
|
-
phase,
|
|
141
|
-
ok,
|
|
142
|
-
files: files ?? [],
|
|
143
|
-
message,
|
|
144
|
-
},
|
|
211
|
+
data: { generation, phase, ok, files: files ?? [], message, ...(scope !== undefined ? { scope } : {}) },
|
|
145
212
|
});
|
|
146
213
|
}
|
|
147
|
-
async #
|
|
214
|
+
async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
148
215
|
const started = Date.now();
|
|
149
216
|
this.#inFlight += 1;
|
|
150
|
-
const run = this.#
|
|
151
|
-
this.#
|
|
217
|
+
const run = this.#fastQueue.then(fn, fn);
|
|
218
|
+
this.#fastQueue = run.then(() => undefined).catch(() => undefined);
|
|
152
219
|
try {
|
|
153
220
|
return await run;
|
|
154
221
|
} finally {
|
|
155
222
|
this.#inFlight -= 1;
|
|
156
223
|
this.#workCount += 1;
|
|
157
|
-
this.#logger.verbose(`[
|
|
224
|
+
this.#logger.verbose(`[fast-queue] ${label} finished in ${Date.now() - started}ms`);
|
|
158
225
|
this.#reportMetrics();
|
|
159
226
|
}
|
|
160
227
|
}
|
|
161
228
|
|
|
162
|
-
/** No queued work and no debounced css rebuild, so nothing is lost if the process exits now. */
|
|
163
229
|
get #idle(): boolean {
|
|
164
|
-
return this.#inFlight === 0 && this.#cssRebuildTimer === null;
|
|
230
|
+
return this.#inFlight === 0 && this.#workQueue.size === 0 && this.#cssRebuildTimer === null;
|
|
165
231
|
}
|
|
166
232
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
233
|
+
// Callers catch their own errors: the promise handed in never rejects.
|
|
234
|
+
#runBeside(work: Promise<void>): Promise<void> {
|
|
235
|
+
this.#inFlight += 1;
|
|
236
|
+
const tracked: Promise<void> = work.finally(() => {
|
|
237
|
+
this.#inFlight -= 1;
|
|
238
|
+
this.#beside.delete(tracked);
|
|
239
|
+
this.#reportMetrics();
|
|
240
|
+
});
|
|
241
|
+
this.#beside.add(tracked);
|
|
242
|
+
return tracked;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Idle only: the host recycles on these metrics, and a recycle decided mid-work would truncate it or race the drain.
|
|
172
246
|
#reportMetrics(): void {
|
|
173
247
|
if (!this.#idle || this.#shuttingDown) return;
|
|
174
248
|
BuilderChannel.emit({
|
|
@@ -177,34 +251,31 @@ class IncrementalBuilder {
|
|
|
177
251
|
});
|
|
178
252
|
}
|
|
179
253
|
|
|
180
|
-
|
|
181
|
-
* Finish queued work, then exit so the OS reclaims the bundler arenas. The host restarts a
|
|
182
|
-
* replacement; build requests that arrive during the drain are refused with the same retry error
|
|
183
|
-
* the backend already handles for any other builder restart.
|
|
184
|
-
*/
|
|
254
|
+
// Exits so the OS reclaims the bundler arenas; the host spawns the replacement.
|
|
185
255
|
async shutdown(reason: string): Promise<void> {
|
|
186
256
|
if (this.#shuttingDown) return;
|
|
187
257
|
this.#shuttingDown = true;
|
|
188
258
|
const started = Date.now();
|
|
189
259
|
this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
|
|
190
|
-
//
|
|
191
|
-
//
|
|
192
|
-
// writing its artifacts. The replacement rebuilds every artifact from its boot build anyway, so the
|
|
193
|
-
// batch is not lost; a half-written one would be.
|
|
260
|
+
// Before the drain: a save landing mid-drain would queue behind the awaited tail and `process.exit` would cut
|
|
261
|
+
// its artifacts off half-written; the replacement's boot build redoes that work anyway.
|
|
194
262
|
this.#watcher?.stop();
|
|
195
263
|
if (this.#cssRebuildTimer) {
|
|
196
|
-
// Only
|
|
197
|
-
// boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
|
|
264
|
+
// Only if css landed after the idle report; the replacement's boot build recompiles css, so nothing is lost.
|
|
198
265
|
clearTimeout(this.#cssRebuildTimer);
|
|
199
266
|
this.#cssRebuildTimer = null;
|
|
200
267
|
this.#pendingCssRebuild = null;
|
|
201
268
|
}
|
|
202
|
-
|
|
269
|
+
// The fast lane first: a save it is still handling may queue a batch in the slow one.
|
|
270
|
+
await this.#fastQueue.catch(() => undefined);
|
|
271
|
+
await this.#workQueue.drain();
|
|
203
272
|
await this.#cssRebuildQueue.catch(() => undefined);
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
273
|
+
while (this.#beside.size > 0) await Promise.all([...this.#beside]);
|
|
274
|
+
if (this.#delegations.size > 0)
|
|
275
|
+
this.#logger.verbose(
|
|
276
|
+
`registry saves handed to build workers this session: ${[...this.#delegations].map(([reason, count]) => `${reason}=${count}`).join(", ")}`,
|
|
277
|
+
);
|
|
278
|
+
// Drained queues are not delivered results: `process.exit` drops unflushed ipc writes (a relayed `css-updated`).
|
|
208
279
|
const flushed = await BuilderChannel.drain();
|
|
209
280
|
this.#logger.debug(
|
|
210
281
|
`drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
|
|
@@ -215,8 +286,7 @@ class IncrementalBuilder {
|
|
|
215
286
|
get shuttingDown(): boolean {
|
|
216
287
|
return this.#shuttingDown;
|
|
217
288
|
}
|
|
218
|
-
//* Watch events
|
|
219
|
-
//* page key still carries the app-relative `(libs)/(<lib>)` prefix.
|
|
289
|
+
//* Watch events carry real paths: synced lib pages match by `realDir`, their keys keep the `(libs)/(<lib>)` prefix.
|
|
220
290
|
static #matchPageRoot(roots: PageRoot[], abs: string): PageRoot | null {
|
|
221
291
|
for (const root of roots) {
|
|
222
292
|
const absRoot = path.resolve(root.realDir);
|
|
@@ -244,7 +314,6 @@ class IncrementalBuilder {
|
|
|
244
314
|
}
|
|
245
315
|
return false;
|
|
246
316
|
}
|
|
247
|
-
/** Debounced css-only rebuild; the compile itself runs in a disposable worker like every other build. */
|
|
248
317
|
scheduleCssRebuild({ generation, changedFiles }: { generation?: number; changedFiles?: string[] }) {
|
|
249
318
|
this.#pendingCssRebuild = { generation, changedFiles };
|
|
250
319
|
if (this.#cssRebuildTimer) clearTimeout(this.#cssRebuildTimer);
|
|
@@ -279,13 +348,12 @@ class IncrementalBuilder {
|
|
|
279
348
|
}, 150);
|
|
280
349
|
}
|
|
281
350
|
async installWatcher() {
|
|
282
|
-
const artifactDir = this.#artifactDir;
|
|
283
351
|
const roots = await new WatchRootResolver(this.#app).resolve();
|
|
284
352
|
const watcher = new HmrWatcher({
|
|
285
353
|
roots,
|
|
286
354
|
logger: this.#logger,
|
|
287
355
|
onBatch: async (batch: ChangeBatch) => {
|
|
288
|
-
await this.#
|
|
356
|
+
await this.#enqueueFast("hmr-batch", async () => this.#handleWatchBatch(batch));
|
|
289
357
|
},
|
|
290
358
|
});
|
|
291
359
|
await watcher.start();
|
|
@@ -293,13 +361,12 @@ class IncrementalBuilder {
|
|
|
293
361
|
this.#logger.verbose(`watching ${roots.length} roots`);
|
|
294
362
|
}
|
|
295
363
|
|
|
296
|
-
async #handleWatchBatch(
|
|
364
|
+
async #handleWatchBatch(batch: ChangeBatch) {
|
|
297
365
|
const rawKinds = new Set(batch.kinds);
|
|
298
366
|
if (rawKinds.size === 0) return;
|
|
299
367
|
const generation = ++this.#generation;
|
|
300
|
-
|
|
301
|
-
//*
|
|
302
|
-
//* rebuild in this same generation; the write is idempotent so it does not re-trigger the watcher.
|
|
368
|
+
const trace = { ...batch.trace, batchAt: Date.now() };
|
|
369
|
+
//* Auto-import edits touch only this batch's files, so they rebuild in this same generation.
|
|
303
370
|
const [autoImport, indexSync] = await CodegenLock.run(
|
|
304
371
|
this.#app.workspace.workspaceRoot,
|
|
305
372
|
`hmr-batch:${this.#app.name}`,
|
|
@@ -311,9 +378,7 @@ class IncrementalBuilder {
|
|
|
311
378
|
for (const error of autoImport.errors) this.#logger.error(error);
|
|
312
379
|
if (autoImport.changedFiles.length > 0)
|
|
313
380
|
this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
|
|
314
|
-
//*
|
|
315
|
-
//* them to the watcher so its verification scan does not read them back as a user edit and spend a
|
|
316
|
-
//* second generation rebuilding identical content.
|
|
381
|
+
//* Absorbed so the watcher's verification scan does not replay these writes as a user edit next generation.
|
|
317
382
|
await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
|
|
318
383
|
const { files, kinds, expandedBatch, devPlan, event, hasSyncErrors } = prepareDevWatchBatch({
|
|
319
384
|
generation,
|
|
@@ -326,24 +391,26 @@ class IncrementalBuilder {
|
|
|
326
391
|
);
|
|
327
392
|
for (const error of indexSync.errors) this.#logger.error(error);
|
|
328
393
|
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
else this.#discovery.invalidate?.(files);
|
|
333
|
-
this.#logger.verbose(
|
|
334
|
-
`client-entry-discovery ${kinds.includes("config") ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`,
|
|
335
|
-
);
|
|
336
|
-
}
|
|
394
|
+
// Invalidated in the slow lane, under the route builds that read it; a registry check reading it beside them caches
|
|
395
|
+
// nothing a walk began before the invalidation (the discovery's epoch).
|
|
396
|
+
const discovery = kinds.includes("code") ? { files, refresh: kinds.includes("config"), generation } : undefined;
|
|
337
397
|
|
|
338
398
|
if (hasSyncErrors) {
|
|
399
|
+
this.#barrelFailed = true;
|
|
339
400
|
this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
|
|
340
401
|
BuilderChannel.emit(event);
|
|
402
|
+
if (discovery)
|
|
403
|
+
void this.#workQueue
|
|
404
|
+
.enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
|
|
405
|
+
.catch(this.#slowLaneFailed("discovery"));
|
|
341
406
|
return;
|
|
342
407
|
}
|
|
343
|
-
|
|
408
|
+
// After a failure too, where nothing changed on disk: the host re-sends a phase still failing to every new tab.
|
|
409
|
+
if (indexSync.changedFiles.length > 0 || this.#barrelFailed)
|
|
410
|
+
this.#sendBuildStatus("barrel", { generation, ok: true, files });
|
|
411
|
+
this.#barrelFailed = false;
|
|
344
412
|
|
|
345
|
-
// Server-only generations
|
|
346
|
-
// client: a fresh pages buildId would broadcast rsc-refresh to browsers for no visible change.
|
|
413
|
+
// Server-only generations skip the client: a fresh pages buildId would rsc-refresh browsers for no visible change.
|
|
347
414
|
const rebuildClient = devPlan.actions.includes("rebuild-client");
|
|
348
415
|
if (kinds.includes("code") && !rebuildClient) {
|
|
349
416
|
this.#logger.verbose(`client rebuild skipped; devPlan actions=${devPlan.actions.join(",") || "(none)"}`);
|
|
@@ -358,75 +425,392 @@ class IncrementalBuilder {
|
|
|
358
425
|
this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
|
|
359
426
|
}
|
|
360
427
|
|
|
361
|
-
|
|
428
|
+
BuilderChannel.emit(event);
|
|
429
|
+
|
|
362
430
|
if (kinds.includes("code") && rebuildClient) {
|
|
363
|
-
|
|
364
|
-
|
|
431
|
+
// Css rides the pages batch rather than its own debounce: the slow lane folds a burst of saves into one batch.
|
|
432
|
+
const needs: BuildBatchNeed[] = ["pages", "css"];
|
|
433
|
+
if (!this.#csrActive)
|
|
365
434
|
this.#logger.verbose(
|
|
366
435
|
`csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
|
|
367
436
|
);
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
437
|
+
//? One after the other, since two compiles side by side slow both. CSR goes first unless its build is being armed:
|
|
438
|
+
//? a CSR patch then waits behind #csrGate, which SSR tabs must not.
|
|
439
|
+
const patchCsr = async () => (this.#csrActive ? await this.#patchCsr(generation, files, trace) : false);
|
|
440
|
+
const patchSsr = async () => await this.#patchSsr(generation, files, trace);
|
|
441
|
+
let csrToWorker: boolean;
|
|
442
|
+
let ssrToWorker: boolean;
|
|
443
|
+
if (this.#csrArming) {
|
|
444
|
+
ssrToWorker = await patchSsr();
|
|
445
|
+
csrToWorker = await patchCsr();
|
|
446
|
+
} else {
|
|
447
|
+
csrToWorker = await patchCsr();
|
|
448
|
+
ssrToWorker = await patchSsr();
|
|
449
|
+
}
|
|
450
|
+
if (csrToWorker) needs.unshift("csr");
|
|
451
|
+
if (ssrToWorker) needs.unshift("ssr");
|
|
452
|
+
const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
|
|
453
|
+
// A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
|
|
454
|
+
if (needs.includes("csr") || needs.includes("ssr")) await this.#workQueue.enqueueBatch(batch);
|
|
455
|
+
else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
|
|
456
|
+
return;
|
|
375
457
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
//
|
|
381
|
-
|
|
382
|
-
else if (kinds.includes("css")) {
|
|
458
|
+
if (discovery)
|
|
459
|
+
void this.#workQueue
|
|
460
|
+
.enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
|
|
461
|
+
.catch(this.#slowLaneFailed("discovery"));
|
|
462
|
+
// Css-only batches keep the debounce: they arrive in bursts while a stylesheet is edited.
|
|
463
|
+
if (kinds.includes("css")) {
|
|
383
464
|
this.scheduleCssRebuild({ generation, changedFiles: files });
|
|
384
465
|
this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
|
|
385
466
|
}
|
|
386
467
|
}
|
|
387
468
|
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
needs,
|
|
396
|
-
changedFiles,
|
|
397
|
-
}: {
|
|
398
|
-
generation: number;
|
|
399
|
-
needs: BuildBatchNeed[];
|
|
400
|
-
changedFiles: string[];
|
|
401
|
-
}): Promise<BuildBatchResult> {
|
|
469
|
+
// True when the save has to go to a build worker: the patcher handed it back, or threw (the worker reports why).
|
|
470
|
+
async #patchCsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
|
|
471
|
+
const patcher = this.#patcher;
|
|
472
|
+
if (!patcher) return true;
|
|
473
|
+
await this.#csrGate;
|
|
474
|
+
// The arming build this save waited for failed: CSR is off again, and the next request arms it anew.
|
|
475
|
+
if (!this.#csrActive) return false;
|
|
402
476
|
const started = Date.now();
|
|
403
|
-
const
|
|
404
|
-
|
|
477
|
+
const marker = path.join(this.#csrBundler.outDir, CSR_DEV_PATCHING_MARKER);
|
|
478
|
+
await Bun.write(marker, String(process.pid));
|
|
479
|
+
try {
|
|
480
|
+
const result = await patcher.update(files, {
|
|
481
|
+
announce: (update) => {
|
|
482
|
+
const now = Date.now();
|
|
483
|
+
BuilderChannel.emit({
|
|
484
|
+
type: "csr-updated",
|
|
485
|
+
data: {
|
|
486
|
+
generation: update.generation,
|
|
487
|
+
mode: "registry",
|
|
488
|
+
reload: update.reload,
|
|
489
|
+
reason: update.reason,
|
|
490
|
+
patchUrl: update.patchUrl,
|
|
491
|
+
changedIds: update.changedIds,
|
|
492
|
+
trace: { ...trace, patchAt: now, sentAt: now },
|
|
493
|
+
},
|
|
494
|
+
});
|
|
495
|
+
},
|
|
496
|
+
});
|
|
497
|
+
if (result.kind === "delegate") {
|
|
498
|
+
this.#logger.verbose(
|
|
499
|
+
`csr-patch handed to a build worker (${this.#countDelegation("csr", result.reason)}): ${result.reason}`,
|
|
500
|
+
);
|
|
501
|
+
return true;
|
|
502
|
+
}
|
|
503
|
+
this.#sendBuildStatus("csr", { generation, ok: true, files });
|
|
504
|
+
this.#logger.verbose(
|
|
505
|
+
result.kind === "unchanged"
|
|
506
|
+
? `csr-patch unchanged (${Date.now() - started}ms)`
|
|
507
|
+
: `csr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
|
|
508
|
+
);
|
|
509
|
+
return false;
|
|
510
|
+
} catch (err) {
|
|
511
|
+
const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
512
|
+
if (IncrementalBuilder.#isCompileError(err)) {
|
|
513
|
+
this.#sendBuildStatus("csr", { generation, ok: false, files, message });
|
|
514
|
+
return false;
|
|
515
|
+
}
|
|
516
|
+
this.#logger.verbose(`csr-patch threw; a build worker takes this save: ${message}`);
|
|
517
|
+
patcher.forget();
|
|
518
|
+
return true;
|
|
519
|
+
} finally {
|
|
520
|
+
await rm(marker, { force: true });
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// True when the save has to go to a build worker, as for CSR.
|
|
525
|
+
async #patchSsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
|
|
526
|
+
const patcher = this.#ssrPatcher;
|
|
527
|
+
if (!patcher) return true;
|
|
528
|
+
const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
|
|
529
|
+
ServerGraphFile.clientExportsOf(file),
|
|
530
|
+
);
|
|
531
|
+
//? Ahead of this save's own discovery job in the slow lane: which client entries a page still names decides which
|
|
532
|
+
//? pending roots this patch keeps.
|
|
533
|
+
this.#discovery.invalidate?.(files);
|
|
534
|
+
return await this.#withSsrLock(
|
|
535
|
+
async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
//* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
|
|
540
|
+
//* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
|
|
541
|
+
async #ensureSsrEntries(entries: string[], { crashRetry = false }: { crashRetry?: boolean } = {}): Promise<void> {
|
|
542
|
+
if (entries.length === 0) return;
|
|
543
|
+
//? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
|
|
544
|
+
//? registry (boot.json) instead; whatever that build missed is added right after it lands.
|
|
545
|
+
const arming = this.#ssrArming;
|
|
546
|
+
if (arming) {
|
|
547
|
+
void this.#runBeside(
|
|
548
|
+
arming
|
|
549
|
+
.then(async () => await this.#ensureSsrEntries(entries, { crashRetry }))
|
|
550
|
+
.catch(this.#slowLaneFailed("ssr-ensure")),
|
|
551
|
+
);
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
await this.#withSsrLock(async () => {
|
|
555
|
+
const patcher = this.#ssrPatcher;
|
|
556
|
+
// The builder's generation, not the request's: its build-status joins the saves' `ssr` ones.
|
|
557
|
+
const generation = this.#generation;
|
|
558
|
+
if (
|
|
559
|
+
patcher &&
|
|
560
|
+
!(await this.#runSsrPatcher(patcher, [], { roots: entries, onlyRoots: true, batchGeneration: generation }))
|
|
561
|
+
)
|
|
562
|
+
return;
|
|
563
|
+
//? Read, not compiled: with the patcher off, the save's own worker batch already built every entry.
|
|
564
|
+
if (!patcher && (await this.#ssrBundler.holds(entries))) return;
|
|
565
|
+
//? Not retried per route build: the save that fixes the error rebuilds it, where each route build retrying held
|
|
566
|
+
//? the SSR lock that save's patch waits on.
|
|
567
|
+
if (this.#ssrBroken) return;
|
|
568
|
+
if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
|
|
569
|
+
//? Handed over just now with nothing run for them: the retry must find them to hand over again.
|
|
570
|
+
patcher?.releaseHandedOver();
|
|
571
|
+
this.#retryAfterCrash(this.#ssrCrashedAt, entries);
|
|
572
|
+
return;
|
|
573
|
+
}
|
|
574
|
+
await this.#runSsrWorker(generation).catch((err: unknown) => {
|
|
575
|
+
this.#logger.error(
|
|
576
|
+
`ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
577
|
+
);
|
|
578
|
+
});
|
|
579
|
+
//? A worker that died took these entries with it and left them pending nowhere: they are tried once more after the
|
|
580
|
+
//? crash window, but a retry that crashes again waits for the next route build or save.
|
|
581
|
+
if (this.#ssrCrashedAt !== null && !crashRetry) this.#retryAfterCrash(this.#ssrCrashedAt, entries);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
//? Once the crash window closes, for the entries skipped in it: later route builds may name none (the first one
|
|
586
|
+
//? announced every entry to the route cache), so without this a tab could wait on boot.json until the next save.
|
|
587
|
+
#retryAfterCrash(crashedAt: number, entries: string[]): void {
|
|
588
|
+
for (const entry of entries) this.#crashRetryEntries.add(entry);
|
|
589
|
+
if (this.#crashRetryTimer) return;
|
|
590
|
+
const timer = setTimeout(
|
|
591
|
+
() => {
|
|
592
|
+
this.#crashRetryTimer = null;
|
|
593
|
+
const retried = [...this.#crashRetryEntries];
|
|
594
|
+
this.#crashRetryEntries.clear();
|
|
595
|
+
if (this.shuttingDown || retried.length === 0) return;
|
|
596
|
+
void this.#runBeside(this.#ensureSsrEntries(retried, { crashRetry: true })).catch(
|
|
597
|
+
this.#slowLaneFailed("ssr-crash-retry"),
|
|
598
|
+
);
|
|
599
|
+
},
|
|
600
|
+
Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
|
|
601
|
+
);
|
|
602
|
+
timer.unref?.();
|
|
603
|
+
this.#crashRetryTimer = timer;
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
//* The boot build of the SSR registry runs beside the slow lane, holding only the SSR lock: the first page's route
|
|
607
|
+
//* build bundles meanwhile instead of queueing behind it (on apps/akan it cost the first page about a second).
|
|
608
|
+
async armSsrRegistry(): Promise<void> {
|
|
609
|
+
//? `AKAN_DEV_SSR_ARM_DELAY_MS` is a test hook: an E2E has a tab reconnect before the boot build has run.
|
|
610
|
+
const delayMs = Number(process.env.AKAN_DEV_SSR_ARM_DELAY_MS);
|
|
611
|
+
if (Number.isInteger(delayMs) && delayMs > 0) await Bun.sleep(delayMs);
|
|
612
|
+
const arm = async () =>
|
|
613
|
+
await this.#withSsrLock(async () => {
|
|
614
|
+
const generation = this.#generation;
|
|
615
|
+
if (this.#ssrPatcher && !(await this.#runSsrPatcher(this.#ssrPatcher, [], { batchGeneration: generation })))
|
|
616
|
+
return;
|
|
617
|
+
await this.#runSsrWorker(generation);
|
|
618
|
+
}).catch((err: unknown) => {
|
|
619
|
+
this.#logger.error(
|
|
620
|
+
`ssr-registry boot build failed; the next save retries it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
621
|
+
);
|
|
622
|
+
});
|
|
623
|
+
const arming = this.#runBeside(
|
|
624
|
+
(async () => {
|
|
625
|
+
await arm();
|
|
626
|
+
//? A boot build whose worker was killed (a small container's OOM) is tried once more before `boot-armed` lets
|
|
627
|
+
//? the next app boot: retried after that, the two biggest workers of a boot would run side by side.
|
|
628
|
+
const crashedAt = this.#ssrCrashedAt;
|
|
629
|
+
if (crashedAt === null) return;
|
|
630
|
+
while (!this.shuttingDown && Date.now() < crashedAt + IncrementalBuilder.#crashRetryMs) await Bun.sleep(250);
|
|
631
|
+
if (!this.shuttingDown) await arm();
|
|
632
|
+
})(),
|
|
633
|
+
);
|
|
634
|
+
this.#ssrArming = arming;
|
|
635
|
+
await arming;
|
|
636
|
+
this.#ssrArming = null;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
//? A save the user broke fails the same way in a worker, so it is reported here and the patcher keeps its state: the
|
|
640
|
+
//? modules it could not compile stay pending in the graph and the fixing save compiles them.
|
|
641
|
+
static #isCompileError(error: unknown): boolean {
|
|
642
|
+
return error instanceof AggregateError;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Voided so the fast lane need not wait; unhandled, a rejection would exit the builder and drop the watcher with it.
|
|
646
|
+
#slowLaneFailed(label: string) {
|
|
647
|
+
return (error: unknown) =>
|
|
648
|
+
this.#logger.error(
|
|
649
|
+
`${label} failed in the slow lane: ${ApplicationBuildReporter.formatError(error, this.#app.workspace.workspaceRoot)}`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
// A reason's own detail (which import) follows its first colon, so saves of one kind count together.
|
|
654
|
+
#countDelegation(registry: "csr" | "ssr", reason: string): string {
|
|
655
|
+
const key = `${registry}:${reason.split(":")[0]}`;
|
|
656
|
+
const count = (this.#delegations.get(key) ?? 0) + 1;
|
|
657
|
+
this.#delegations.set(key, count);
|
|
658
|
+
return `#${count} for this reason`;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
async #withSsrLock<T>(fn: () => Promise<T>): Promise<T> {
|
|
662
|
+
const run = this.#ssrLock.then(fn, fn);
|
|
663
|
+
this.#ssrLock = run.then(
|
|
664
|
+
() => undefined,
|
|
665
|
+
() => undefined,
|
|
405
666
|
);
|
|
667
|
+
return await run;
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
// Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
|
|
671
|
+
async #runSsrWorker(generation: number): Promise<void> {
|
|
672
|
+
let result: BuildBatchResult;
|
|
673
|
+
try {
|
|
674
|
+
result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
|
|
675
|
+
} finally {
|
|
676
|
+
this.#ssrPatcher?.forget();
|
|
677
|
+
}
|
|
678
|
+
this.#noteSsrWorker(result);
|
|
679
|
+
if (result.errors.ssr) throw new Error(result.errors.ssr);
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
#noteSsrWorker(result: BuildBatchResult): void {
|
|
683
|
+
const crashed = !!result.crashed && (result.crashedNeeds ?? ["ssr"]).includes("ssr");
|
|
684
|
+
this.#ssrBroken = !!result.errors.ssr && !crashed;
|
|
685
|
+
this.#ssrCrashedAt = crashed ? Date.now() : null;
|
|
686
|
+
}
|
|
687
|
+
|
|
688
|
+
async #runSsrPatcher(
|
|
689
|
+
patcher: CsrDevPatcher,
|
|
690
|
+
files: string[],
|
|
691
|
+
{
|
|
692
|
+
roots,
|
|
693
|
+
onlyRoots,
|
|
694
|
+
trace,
|
|
695
|
+
hold,
|
|
696
|
+
batchGeneration,
|
|
697
|
+
}: { roots?: string[]; onlyRoots?: boolean; trace?: HmrTrace; hold?: boolean; batchGeneration?: number } = {},
|
|
698
|
+
): Promise<boolean> {
|
|
699
|
+
const started = Date.now();
|
|
700
|
+
const marker = path.join(this.#ssrBundler.outDir, CSR_DEV_PATCHING_MARKER);
|
|
701
|
+
await Bun.write(marker, String(process.pid));
|
|
702
|
+
try {
|
|
703
|
+
const result = await patcher.update(files, {
|
|
704
|
+
roots,
|
|
705
|
+
onlyRoots,
|
|
706
|
+
announce: (update) => {
|
|
707
|
+
const now = Date.now();
|
|
708
|
+
BuilderChannel.emit({
|
|
709
|
+
type: "ssr-updated",
|
|
710
|
+
data: {
|
|
711
|
+
generation: update.generation,
|
|
712
|
+
reload: update.reload,
|
|
713
|
+
reason: update.reason,
|
|
714
|
+
patchUrl: update.patchUrl,
|
|
715
|
+
changedIds: update.changedIds,
|
|
716
|
+
...(trace ? { trace: { ...trace, patchAt: now, sentAt: now } } : {}),
|
|
717
|
+
...(hold ? { hold } : {}),
|
|
718
|
+
...(batchGeneration !== undefined ? { batchGeneration } : {}),
|
|
719
|
+
...(update.epoch !== undefined ? { epoch: update.epoch } : {}),
|
|
720
|
+
...(update.first ? { first: true } : {}),
|
|
721
|
+
},
|
|
722
|
+
});
|
|
723
|
+
},
|
|
724
|
+
});
|
|
725
|
+
if (result.kind === "delegate") {
|
|
726
|
+
this.#logger.verbose(
|
|
727
|
+
`ssr-patch handed to a build worker (${this.#countDelegation("ssr", result.reason)}): ${result.reason}`,
|
|
728
|
+
);
|
|
729
|
+
return true;
|
|
730
|
+
}
|
|
731
|
+
//? A route build's check reports only a failure: it compiles the roots the registry lacks and nothing else, so
|
|
732
|
+
//? its ok, of the builder's current generation, would read as the fix of a save that broke something else.
|
|
733
|
+
if (!onlyRoots) {
|
|
734
|
+
this.#ssrBroken = false;
|
|
735
|
+
this.#ssrCrashedAt = null;
|
|
736
|
+
this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: true, files });
|
|
737
|
+
}
|
|
738
|
+
this.#logger.verbose(
|
|
739
|
+
result.kind === "unchanged"
|
|
740
|
+
? `ssr-patch unchanged (${Date.now() - started}ms)`
|
|
741
|
+
: `ssr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
|
|
742
|
+
);
|
|
743
|
+
return false;
|
|
744
|
+
} catch (err) {
|
|
745
|
+
const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
746
|
+
if (IncrementalBuilder.#isCompileError(err)) {
|
|
747
|
+
this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: false, files, message });
|
|
748
|
+
return false;
|
|
749
|
+
}
|
|
750
|
+
this.#logger.verbose(`ssr-patch threw; a build worker takes it: ${message}`);
|
|
751
|
+
patcher.forget();
|
|
752
|
+
return true;
|
|
753
|
+
} finally {
|
|
754
|
+
await rm(marker, { force: true });
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
async #runQueuedBatch({ discovery, ...work }: BatchJob): Promise<void> {
|
|
759
|
+
if (discovery) await this.#refreshDiscovery(discovery);
|
|
760
|
+
//? The slow lane runs in order, so every batch up to this one has had its discovery job.
|
|
761
|
+
this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
|
|
762
|
+
// A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
|
|
763
|
+
//? Forgotten inside the lock: a route build's check waiting on it would patch the old state over the worker's.
|
|
764
|
+
try {
|
|
765
|
+
if (work.needs.includes("ssr"))
|
|
766
|
+
await this.#withSsrLock(async () => {
|
|
767
|
+
try {
|
|
768
|
+
this.#noteSsrWorker(await this.#runBatch(work));
|
|
769
|
+
} finally {
|
|
770
|
+
this.#ssrPatcher?.forget();
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
else await this.#runBatch(work);
|
|
774
|
+
} finally {
|
|
775
|
+
if (work.needs.includes("csr")) this.#patcher?.forget();
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
|
|
780
|
+
const started = Date.now();
|
|
781
|
+
if (refresh) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
|
|
782
|
+
else this.#discovery.invalidate?.(files);
|
|
783
|
+
this.#discoveredThrough = Math.max(this.#discoveredThrough, generation);
|
|
784
|
+
this.#logger.verbose(`client-entry-discovery ${refresh ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
async #runBatch(work: BatchWork): Promise<BuildBatchResult> {
|
|
788
|
+
const { generation, needs, changedFiles } = work;
|
|
789
|
+
const started = Date.now();
|
|
790
|
+
const result = await this.#batchRunner.run(await this.#batchRequest(work), (msg) => BuilderChannel.emit(msg));
|
|
406
791
|
if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
|
|
407
792
|
if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
|
|
408
|
-
// A worker
|
|
409
|
-
// it was given: the generation must go red rather than look like it silently succeeded.
|
|
793
|
+
// A need the worker died before reporting goes red here instead of looking silently successful.
|
|
410
794
|
if (result.crashed) {
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
795
|
+
const crashedNeeds = result.crashedNeeds ?? needs;
|
|
796
|
+
//? A worker that died wrote no failure for the roots handed to it: they must not wait on one that never comes.
|
|
797
|
+
if (crashedNeeds.includes("csr")) this.#patcher?.releaseHandedOver();
|
|
798
|
+
if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
|
|
799
|
+
// `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
|
|
800
|
+
for (const need of crashedNeeds)
|
|
414
801
|
if (need !== "base")
|
|
415
|
-
this.#sendBuildStatus(need, {
|
|
802
|
+
this.#sendBuildStatus(need, {
|
|
803
|
+
generation,
|
|
804
|
+
ok: false,
|
|
805
|
+
files: changedFiles,
|
|
806
|
+
message: `${result.errors[need] ?? "the build worker crashed"}; the next save builds it again`,
|
|
807
|
+
});
|
|
416
808
|
}
|
|
417
809
|
if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
|
|
418
810
|
return result;
|
|
419
811
|
}
|
|
420
812
|
|
|
421
|
-
async #batchRequest({
|
|
422
|
-
generation,
|
|
423
|
-
needs,
|
|
424
|
-
changedFiles,
|
|
425
|
-
}: {
|
|
426
|
-
generation: number;
|
|
427
|
-
needs: BuildBatchNeed[];
|
|
428
|
-
changedFiles: string[];
|
|
429
|
-
}): Promise<BuildBatchRequest> {
|
|
813
|
+
async #batchRequest({ generation, needs, changedFiles, trace }: BatchWork): Promise<BuildBatchRequest> {
|
|
430
814
|
return {
|
|
431
815
|
appName: this.#app.name,
|
|
432
816
|
workspaceRoot: this.#app.workspace.workspaceRoot,
|
|
@@ -438,6 +822,7 @@ class IncrementalBuilder {
|
|
|
438
822
|
optimizedFonts: this.#optimizedFonts,
|
|
439
823
|
cssAssets: this.#artifact.cssAssets ?? null,
|
|
440
824
|
artifactDir: path.resolve(this.#artifactDir),
|
|
825
|
+
...(trace ? { trace } : {}),
|
|
441
826
|
};
|
|
442
827
|
}
|
|
443
828
|
|
|
@@ -447,32 +832,20 @@ class IncrementalBuilder {
|
|
|
447
832
|
this.#logger.verbose(`ready (watch=${this.#watch})`);
|
|
448
833
|
}
|
|
449
834
|
|
|
450
|
-
|
|
451
|
-
* After a degraded boot recovers, the backend is still serving the last-good bundle; push a
|
|
452
|
-
* fresh pages/css state so connected browsers pick up the fixed code without another edit.
|
|
453
|
-
*/
|
|
835
|
+
// The backend still serves the last-good bundle after a degraded boot; push fresh pages/css without another edit.
|
|
454
836
|
async announceRecoveredState(changedFiles: string[]): Promise<void> {
|
|
455
837
|
const generation = ++this.#generation;
|
|
456
|
-
await this.#
|
|
838
|
+
await this.#workQueue.enqueue("boot-recovered", async () => {
|
|
457
839
|
await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
|
|
458
840
|
});
|
|
459
841
|
}
|
|
460
842
|
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
*
|
|
464
|
-
* The backend reads `base-artifact.json` once at boot and never re-reads it, so a pages or css hash
|
|
465
|
-
* that moved while the builder was being replaced would otherwise leave it pointing at the previous
|
|
466
|
-
* artifact until the next save. Announced from the boot artifact rather than by rebuilding: the
|
|
467
|
-
* bundles are already on disk, and spending another ~200MB of bundler arena in a process that was
|
|
468
|
-
* just recycled to reclaim memory would defeat the purpose. The host suppresses the announcement
|
|
469
|
-
* when the hashes match, which is the common case, so a clean recycle never reloads a browser.
|
|
470
|
-
*/
|
|
843
|
+
// The backend reads base-artifact.json once, at its boot. Re-announced from disk rather than rebuilt (this process
|
|
844
|
+
// was recycled to free bundler memory); the host drops it when hashes match, so a clean recycle reloads nothing.
|
|
471
845
|
async announceBootState(): Promise<void> {
|
|
472
846
|
const generation = ++this.#generation;
|
|
473
847
|
const reason = "builder-recycle" as const;
|
|
474
|
-
// Awaited
|
|
475
|
-
// down at any moment, and "announced boot state" must mean the announcement left the process.
|
|
848
|
+
// Awaited, not emitted: the host may shut this builder down any moment, so "announced" must mean flushed.
|
|
476
849
|
await BuilderChannel.send({
|
|
477
850
|
type: "pages-updated",
|
|
478
851
|
data: {
|
|
@@ -499,51 +872,64 @@ class IncrementalBuilder {
|
|
|
499
872
|
this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
|
|
500
873
|
}
|
|
501
874
|
|
|
502
|
-
|
|
503
|
-
* Build the dev CSR artifact because a request asked for it, and keep it in sync from now on. The
|
|
504
|
-
* dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
|
|
505
|
-
* points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
|
|
506
|
-
*/
|
|
875
|
+
// On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
|
|
507
876
|
async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
|
|
508
|
-
|
|
877
|
+
//? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
|
|
878
|
+
this.#beginCsrArm();
|
|
879
|
+
this.#csrActive = true;
|
|
880
|
+
const armed = this.#workQueue.enqueue("build-csr", async (): Promise<void> => {
|
|
509
881
|
const started = Date.now();
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
await this.#
|
|
514
|
-
|
|
515
|
-
|
|
882
|
+
let error: string | undefined;
|
|
883
|
+
try {
|
|
884
|
+
// Not relayed: an on-demand CSR build is request/response, and its error travels in the response.
|
|
885
|
+
const result = await this.#batchRunner.run(
|
|
886
|
+
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
887
|
+
);
|
|
888
|
+
error = result.errors.csr;
|
|
889
|
+
} catch (err) {
|
|
890
|
+
error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
|
|
891
|
+
}
|
|
516
892
|
if (error) {
|
|
517
893
|
this.#logger.error(`csr-build failed: ${error}`);
|
|
518
894
|
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
|
|
519
895
|
return;
|
|
520
896
|
}
|
|
521
|
-
this.#
|
|
897
|
+
this.#csrArmSucceeded = true;
|
|
898
|
+
this.#patcher?.forget();
|
|
522
899
|
this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
|
|
523
900
|
await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
|
|
524
901
|
});
|
|
902
|
+
this.#csrGate = this.#armingCsr(armed);
|
|
903
|
+
await armed;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
//? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
|
|
907
|
+
//? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
|
|
908
|
+
//? patch CSR first while the second still builds.
|
|
909
|
+
#beginCsrArm(): void {
|
|
910
|
+
if (this.#csrArms === 0) {
|
|
911
|
+
this.#csrWasActive = this.#csrActive;
|
|
912
|
+
this.#csrArmSucceeded = false;
|
|
913
|
+
}
|
|
914
|
+
this.#csrArms += 1;
|
|
915
|
+
this.#csrArming = true;
|
|
525
916
|
}
|
|
526
917
|
|
|
527
|
-
#
|
|
528
|
-
|
|
918
|
+
#armingCsr(armed: Promise<void>): Promise<void> {
|
|
919
|
+
const settle = () => {
|
|
920
|
+
this.#csrArms -= 1;
|
|
921
|
+
if (this.#csrArms > 0) return;
|
|
922
|
+
this.#csrArming = false;
|
|
923
|
+
this.#csrActive = this.#csrWasActive || this.#csrArmSucceeded;
|
|
924
|
+
};
|
|
925
|
+
return armed.then(settle, settle);
|
|
529
926
|
}
|
|
530
927
|
|
|
531
928
|
static #csrArmedByEnv() {
|
|
532
929
|
return process.env.AKAN_DEV_CSR_REBUILD === "1";
|
|
533
930
|
}
|
|
534
931
|
|
|
535
|
-
|
|
536
|
-
* Build the boot artifact in a process that exits afterwards, and keep only the serializable result.
|
|
537
|
-
*
|
|
538
|
-
* This runs in a worker for the same reason every other build does, and it was the largest single
|
|
539
|
-
* holdout: measured on `apps/akan`, `SsrBaseArtifactBuilder.build()` retains **+1143 MB** that
|
|
540
|
-
* `Bun.gc(true)` cannot touch, which is 65 % of the builder's post-boot RSS. Nothing was lost by
|
|
541
|
-
* moving it — the builder only ever kept `artifact` and `optimizedFonts`, both plain data, and the
|
|
542
|
-
* artifact is written to `base-artifact.json` regardless.
|
|
543
|
-
*
|
|
544
|
-
* `GraphClientEntryDiscovery.create` stays here because route builds need it live, and it costs
|
|
545
|
-
* nothing to keep: measured at **0 ms and 0 MB**, because it builds its graph lazily on first use.
|
|
546
|
-
*/
|
|
932
|
+
// Discovery stays in-process because route builds need it live; it is free at boot, building its graph lazily.
|
|
547
933
|
static async #buildBootDeps(app: App, runner: BuildBatchRunner): Promise<IncrementalBuilderBootDeps> {
|
|
548
934
|
const result = await runner.run({
|
|
549
935
|
appName: app.name,
|
|
@@ -552,15 +938,12 @@ class IncrementalBuilder {
|
|
|
552
938
|
generation: 0,
|
|
553
939
|
needs: ["base"],
|
|
554
940
|
changedFiles: [],
|
|
555
|
-
// Discovered by the worker: at boot the watcher has no validated keys to seed, and the boot build
|
|
556
|
-
// globs them itself anyway.
|
|
557
941
|
pageKeys: null,
|
|
558
942
|
optimizedFonts: null,
|
|
559
943
|
cssAssets: null,
|
|
560
944
|
artifactDir: path.resolve(`${app.cwdPath}/.akan/artifact`),
|
|
561
945
|
});
|
|
562
|
-
//
|
|
563
|
-
// watch mode that keeps the dev server alive until the error is fixed.
|
|
946
|
+
// Throws rather than degrading quietly: `main` catches it to enter degraded watch mode.
|
|
564
947
|
if (result.errors.base) throw new Error(result.errors.base);
|
|
565
948
|
if (!result.artifact || !result.optimizedFonts)
|
|
566
949
|
throw new Error("boot build reported success without an artifact; the build worker likely died");
|
|
@@ -568,40 +951,43 @@ class IncrementalBuilder {
|
|
|
568
951
|
return { artifact: result.artifact, optimizedFonts: result.optimizedFonts, discovery };
|
|
569
952
|
}
|
|
570
953
|
|
|
571
|
-
|
|
572
|
-
* A session that already armed dev CSR keeps it armed across builder restarts through the env flag,
|
|
573
|
-
* so the artifact has to be rebuilt for the replacement. Queued after `builder-ready` and run in a
|
|
574
|
-
* disposable worker rather than inline: a full minified browser-target build of every page costs
|
|
575
|
-
* ~350MB of bundler arena that an inline build would never give back, and nothing serves CSR until a
|
|
576
|
-
* `/__csr` or `?csr=true` request arrives anyway.
|
|
577
|
-
*/
|
|
954
|
+
// The env flag carries an armed CSR session across builder restarts, so the replacement rebuilds it after ready.
|
|
578
955
|
async rearmCsrFromEnv(): Promise<void> {
|
|
579
956
|
if (!IncrementalBuilder.#csrArmedByEnv()) return;
|
|
957
|
+
this.#beginCsrArm();
|
|
580
958
|
this.#csrActive = true;
|
|
581
|
-
|
|
959
|
+
//? The env keeps CSR armed whatever this build does, as it did before the builder restarted.
|
|
960
|
+
this.#csrArmSucceeded = true;
|
|
961
|
+
const rearmed = this.#workQueue.enqueue("build-csr-rearm", async () => {
|
|
962
|
+
//? Relayed, unlike an on-demand build: a rebuild for a metadata save must reach the CSR tabs as their reload.
|
|
582
963
|
const result = await this.#batchRunner.run(
|
|
583
964
|
await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
|
|
965
|
+
(msg) => BuilderChannel.emit(msg),
|
|
584
966
|
);
|
|
967
|
+
this.#patcher?.forget();
|
|
585
968
|
if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
|
|
586
969
|
else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
|
|
587
970
|
});
|
|
971
|
+
this.#csrGate = this.#armingCsr(rearmed);
|
|
972
|
+
await rearmed.catch((err: unknown) =>
|
|
973
|
+
this.#logger.error(
|
|
974
|
+
`csr-rearm failed: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
|
|
975
|
+
),
|
|
976
|
+
);
|
|
588
977
|
}
|
|
589
978
|
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
* file watcher, so exiting here leaves nothing to notice the fix. Report the failure, emit
|
|
593
|
-
* builder-ready so the host keeps the backend serving the last-good artifact, then retry the
|
|
594
|
-
* boot build on every file change until it succeeds.
|
|
595
|
-
*/
|
|
979
|
+
// A boot compile error must not kill the builder, the dev server's only file watcher: report it, emit builder-ready
|
|
980
|
+
// so the backend keeps the last-good artifact, and retry the boot build on every change.
|
|
596
981
|
static #recoverBoot(
|
|
597
982
|
app: App,
|
|
598
983
|
bootError: unknown,
|
|
599
984
|
logger: Logger,
|
|
600
985
|
runner: BuildBatchRunner,
|
|
986
|
+
initialGeneration: number,
|
|
601
987
|
): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
|
|
602
988
|
const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
|
|
603
989
|
logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
|
|
604
|
-
let generation =
|
|
990
|
+
let generation = initialGeneration;
|
|
605
991
|
const sendFailure = (files: string[], message: string) => {
|
|
606
992
|
BuilderChannel.emit({
|
|
607
993
|
type: "build-status",
|
|
@@ -610,6 +996,8 @@ class IncrementalBuilder {
|
|
|
610
996
|
};
|
|
611
997
|
sendFailure([], firstMessage);
|
|
612
998
|
BuilderChannel.emit({ type: "builder-ready" });
|
|
999
|
+
// Nothing builds until a save fixes the boot, so the next app's boot need not wait for this one.
|
|
1000
|
+
BuilderChannel.emit({ type: "boot-armed" });
|
|
613
1001
|
return new Promise((resolve, reject) => {
|
|
614
1002
|
void (async () => {
|
|
615
1003
|
const roots = await new WatchRootResolver(app).resolve();
|
|
@@ -648,8 +1036,7 @@ class IncrementalBuilder {
|
|
|
648
1036
|
const app = AppExecutor.from(workspace, appName);
|
|
649
1037
|
const watch = process.env.AKAN_WATCH !== "0";
|
|
650
1038
|
let builder: IncrementalBuilder | null = null;
|
|
651
|
-
// Registered before the boot build so
|
|
652
|
-
// the backend) while the builder is still booting or recovering from a failed build.
|
|
1039
|
+
// Registered before the boot build so a request during boot or recovery gets an error instead of hanging.
|
|
653
1040
|
const bootingError = "builder is recovering from a failed boot build; retry after the build error is fixed";
|
|
654
1041
|
const recyclingError = "builder is recycling to release bundler memory; retry after it restarts";
|
|
655
1042
|
process.on("message", (msg: BuilderMessage) => {
|
|
@@ -662,51 +1049,52 @@ class IncrementalBuilder {
|
|
|
662
1049
|
void builder.shutdown(msg.reason);
|
|
663
1050
|
return;
|
|
664
1051
|
}
|
|
665
|
-
if (msg.type
|
|
1052
|
+
if (msg.type !== "build-route" && msg.type !== "build-csr") return;
|
|
1053
|
+
if (!builder || builder.shuttingDown) {
|
|
666
1054
|
const error = builder?.shuttingDown ? recyclingError : bootingError;
|
|
667
|
-
|
|
668
|
-
BuilderChannel.emit({ type: "build-route-res", id: msg.id, ok: false, error });
|
|
669
|
-
return;
|
|
670
|
-
}
|
|
671
|
-
void builder.handleBuildRoute(msg);
|
|
1055
|
+
BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
|
|
672
1056
|
return;
|
|
673
1057
|
}
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
void builder.handleBuildCsr(msg);
|
|
681
|
-
}
|
|
1058
|
+
const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
|
|
1059
|
+
// Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
|
|
1060
|
+
void handled.catch((err: unknown) =>
|
|
1061
|
+
logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
|
|
1062
|
+
);
|
|
682
1063
|
});
|
|
683
|
-
//
|
|
684
|
-
// as an orphaned watcher that keeps rebuilding for nobody. Nothing is drained here on purpose —
|
|
685
|
-
// there is no longer anyone on the other end to flush to.
|
|
1064
|
+
// Closes when the host dies (even by SIGKILL): nothing left to drain, and an orphan would rebuild for nobody.
|
|
686
1065
|
process.on("disconnect", () => {
|
|
687
1066
|
logger.warn("host IPC channel closed; exiting builder");
|
|
688
1067
|
process.exit(0);
|
|
689
1068
|
});
|
|
690
1069
|
let recoveredFiles: string[] | null = null;
|
|
691
|
-
// Owned by `main` rather than the instance: the boot build has to run before an instance exists, and
|
|
692
|
-
// a degraded boot re-runs it once per file change until it succeeds.
|
|
693
1070
|
const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
|
|
1071
|
+
const initialGeneration = IncrementalBuilder.#initialGeneration();
|
|
694
1072
|
try {
|
|
695
|
-
|
|
1073
|
+
const deps = await IncrementalBuilder.#buildBootDeps(app, bootRunner);
|
|
1074
|
+
builder = new IncrementalBuilder({ app, watch, initialGeneration, ...deps });
|
|
696
1075
|
} catch (err) {
|
|
697
1076
|
if (!watch) throw err;
|
|
698
|
-
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
|
|
1077
|
+
const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner, initialGeneration);
|
|
699
1078
|
builder = recovered.builder;
|
|
700
1079
|
recoveredFiles = recovered.changedFiles;
|
|
701
1080
|
}
|
|
702
1081
|
await builder.boot();
|
|
703
1082
|
if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
|
|
704
1083
|
else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
|
|
705
|
-
await builder.rearmCsrFromEnv();
|
|
1084
|
+
await Promise.all([builder.rearmCsrFromEnv(), builder.armSsrRegistry()]);
|
|
1085
|
+
BuilderChannel.emit({ type: "boot-armed" });
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
//? A replacement builder continues past the host's last generation: from 0, the save fixing an error read older than
|
|
1089
|
+
//? the failure the tabs hold, and from that generation itself its arming builds' ok did not read as the fix either.
|
|
1090
|
+
static #initialGeneration(): number {
|
|
1091
|
+
const generation = Number(process.env.AKAN_BUILDER_INITIAL_GENERATION);
|
|
1092
|
+
return Number.isInteger(generation) && generation > 0 ? generation + 1 : 0;
|
|
706
1093
|
}
|
|
707
1094
|
}
|
|
708
1095
|
|
|
709
|
-
|
|
1096
|
+
// Awaited, not voided: on Windows Bun exits during a missing file's `Bun.file` read unless an entry await is pending.
|
|
1097
|
+
await IncrementalBuilder.main().catch((err) => {
|
|
710
1098
|
console.error(err);
|
|
711
1099
|
process.exit(1);
|
|
712
1100
|
});
|