@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20
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/abstractDoc.test.ts +6 -15
- package/abstractDoc.ts +1 -4
- 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 +1 -3
- package/akanApp/akanApp.host.ts +64 -250
- package/akanApp/devHostPolicy.test.ts +10 -34
- package/akanApp/devHostPolicy.ts +12 -98
- package/akanConfig/akanConfig.test.ts +30 -39
- package/akanConfig/akanConfig.ts +40 -136
- package/akanContext.ts +106 -159
- package/akanMcpContract.ts +28 -73
- package/appSelectionMemory.ts +1 -4
- package/applicationBuildReporter.ts +37 -5
- package/applicationBuildRunner.ts +25 -59
- package/applicationReleasePackager.ts +24 -44
- package/applicationTestPreload.ts +3 -12
- package/artifact/implicitRootLayout.test.ts +3 -19
- package/artifact/implicitRootLayout.ts +34 -91
- package/artifact/routeSeedIndex.ts +11 -44
- package/biomeBase.ts +2 -5
- package/capacitorApp.test.ts +4 -14
- package/capacitorApp.ts +86 -235
- package/cloud/cloudApi.test.ts +202 -0
- package/cloud/cloudApi.ts +33 -57
- package/cloud/configLock.test.ts +64 -0
- package/cloud/configLock.ts +96 -0
- package/cloud/constants.ts +3 -2
- package/cloud/globalConfig.ts +64 -31
- package/codeAgent/agent/AkanCodeServices.ts +15 -57
- package/codeAgent/agent/AkanContextFiles.ts +5 -28
- package/codeAgent/agent/CodeAgent.ts +33 -108
- package/codeAgent/agent/CodeAgentAsks.ts +7 -30
- package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
- package/codeAgent/agent/CodeAgentGate.ts +8 -34
- package/codeAgent/agent/CodeAgentProxy.ts +4 -13
- package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
- package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
- package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
- package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
- package/codeAgent/agent/CodeAgentUi.ts +2 -7
- package/codeAgent/agent/CodeMailbox.ts +7 -34
- package/codeAgent/agent/CodeSessionFork.ts +4 -19
- package/codeAgent/agent/CodeSessionIndex.ts +5 -31
- package/codeAgent/agent/SubagentPool.ts +48 -58
- package/codeAgent/agent/akanCodeModel.ts +5 -39
- package/codeAgent/agent/akanCodePaths.ts +4 -24
- package/codeAgent/agent/akanContextFiles.test.ts +0 -2
- package/codeAgent/agent/akanSystemPrompt.ts +1 -9
- package/codeAgent/agent/codeAgent.test.ts +178 -0
- package/codeAgent/agent/codeAgentContract.test.ts +5 -28
- package/codeAgent/agent/codeMailbox.test.ts +0 -4
- package/codeAgent/agent/codeSessionFork.test.ts +0 -2
- package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
- package/codeAgent/agent/consoleToStderr.ts +2 -12
- package/codeAgent/agent/jsonLines.ts +29 -0
- package/codeAgent/agent/stringArg.ts +5 -0
- package/codeAgent/feedback/DevLogFeedback.ts +3 -18
- package/codeAgent/feedback/PreviewChrome.ts +5 -29
- package/codeAgent/feedback/PreviewFeedback.ts +2 -9
- package/codeAgent/feedback/PreviewView.ts +3 -15
- package/codeAgent/feedback/TurnFeedback.ts +2 -17
- package/codeAgent/feedback/VerifyFeedback.ts +1 -7
- package/codeAgent/feedback/previewChrome.test.ts +1 -1
- package/codeAgent/index.ts +2 -10
- package/codeAgent/skills/akan-code/SKILL.md +12 -4
- package/codeAgent/tools/AkanCli.ts +4 -16
- package/codeAgent/tools/AkanCodePlugins.ts +21 -43
- package/codeAgent/tools/AkanEditScope.ts +2 -12
- package/codeAgent/tools/AkanEnvKeys.ts +3 -11
- package/codeAgent/tools/AkanToolPack.ts +6 -28
- package/codeAgent/tools/AkanVerifier.ts +1 -8
- package/codeAgent/tools/AskToolPack.ts +3 -15
- package/codeAgent/tools/MailToolPack.ts +0 -9
- package/codeAgent/tools/McpClient.ts +3 -24
- package/codeAgent/tools/McpOAuth.ts +6 -41
- package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
- package/codeAgent/tools/McpServerConfig.ts +7 -26
- package/codeAgent/tools/McpSignIn.ts +46 -49
- package/codeAgent/tools/McpTokenStore.ts +6 -17
- package/codeAgent/tools/McpToolPack.ts +107 -35
- package/codeAgent/tools/SessionToolPack.ts +1 -11
- package/codeAgent/tools/WebToolPack.ts +1 -7
- package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
- package/codeAgent/tools/askToolPack.test.ts +0 -3
- package/codeAgent/tools/mcpOAuth.test.ts +315 -27
- package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
- 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 +44 -102
- package/commandDecorators/commandBuilder.ts +30 -61
- package/commandDecorators/commandDecorators.test.ts +1 -9
- package/commandDecorators/dependencyBuilder.ts +10 -1
- package/commandDecorators/helpFormatter.ts +66 -144
- package/commandDecorators/index.ts +0 -2
- package/commandDecorators/targetMeta.ts +9 -7
- package/createTunnel.ts +7 -14
- package/dependencyScanner.test.ts +4 -16
- package/dependencyScanner.ts +27 -78
- package/devkitUtils.test.ts +47 -19
- package/executors.test.ts +114 -60
- package/executors.ts +165 -484
- package/fileEditor.ts +9 -37
- package/{getDirname.test.ts → fileSys.test.ts} +1 -1
- package/fileSys.ts +6 -8
- package/formSetterScanner.ts +3 -16
- package/frontendBuild/allRoutesBuilder.ts +7 -21
- package/frontendBuild/autoImportSync.test.ts +14 -38
- package/frontendBuild/autoImportSync.ts +15 -67
- package/frontendBuild/buildRouteClient.test.ts +10 -36
- package/frontendBuild/bundleDefine.ts +12 -0
- package/frontendBuild/clientBuildTypes.ts +5 -36
- package/frontendBuild/clientEntriesBundler.ts +9 -40
- package/frontendBuild/clientEntryDiscovery.ts +6 -41
- package/frontendBuild/csrArtifactBuilder.ts +6 -25
- package/frontendBuild/cssCandidateCache.test.ts +0 -6
- package/frontendBuild/cssCandidateCache.ts +6 -41
- package/frontendBuild/cssCompiler.ts +29 -78
- package/frontendBuild/cssImportResolver.ts +5 -18
- package/frontendBuild/devChangePlanner.ts +7 -10
- package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
- package/frontendBuild/devGeneratedIndexSync.ts +1 -4
- package/frontendBuild/fontOptimizer.test.ts +5 -13
- package/frontendBuild/fontOptimizer.ts +15 -72
- package/frontendBuild/fontPruner.test.ts +5 -10
- package/frontendBuild/fontPruner.ts +5 -20
- package/frontendBuild/frontendBuild.test.ts +27 -52
- package/frontendBuild/hmrChangeClassifier.ts +2 -2
- package/frontendBuild/hmrWatcher.test.ts +7 -31
- package/frontendBuild/hmrWatcher.ts +14 -71
- package/frontendBuild/pagesBundleBuilder.ts +4 -29
- package/frontendBuild/precompressArtifacts.ts +5 -18
- package/frontendBuild/routeClientBuilder.ts +7 -47
- package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
- package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
- package/frontendBuild/sourceMtimeIndex.ts +19 -130
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
- 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 +22 -57
- package/incrementalBuilder/buildBatchProtocol.ts +8 -36
- package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
- package/incrementalBuilder/buildBatchRunner.ts +10 -31
- 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/devWatchBatch.test.ts +14 -18
- package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
- package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
- package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
- package/index.ts +3 -15
- package/integration/devResourceProbe.ts +50 -56
- package/integration/devStability.integration.test.ts +47 -194
- package/integration/devStabilityHarness.test.ts +9 -29
- 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/gritRules.test.ts +6 -23
- package/linter.test.ts +1 -2
- package/linter.ts +22 -115
- package/mobile/mobileTarget.ts +14 -13
- package/package.json +2 -2
- package/packageExports.test.ts +3 -18
- package/packageExportsMap.ts +3 -19
- package/piDependencyPins.test.ts +3 -14
- package/platformTest/IdleWatchedProcess.test.ts +5 -13
- package/platformTest/IdleWatchedProcess.ts +1 -5
- package/platformTest/LinuxDockerTarget.ts +20 -35
- package/platformTest/PlatformTestRun.test.ts +5 -9
- package/platformTest/PlatformTestTarget.ts +24 -2
- package/platformTest/TestSnapshot.test.ts +6 -14
- package/platformTest/TestSnapshot.ts +1 -5
- package/platformTest/WindowsSshTarget.ts +6 -22
- package/prompter.ts +3 -6
- package/qualityScanner.test.ts +6 -24
- package/qualityScanner.ts +196 -284
- package/recipeScanner.test.ts +7 -12
- package/recipeScanner.ts +9 -38
- package/repoIdentity.ts +2 -9
- package/routeSourceValidator.ts +7 -30
- package/scanInfo.ts +82 -214
- 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/subspace.test.ts +6 -25
- package/subspace.ts +31 -109
- package/subspaceConfig.ts +3 -11
- package/testHelpers.ts +49 -34
- 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 +35 -20
- package/transforms/transforms.test.ts +153 -44
- package/transforms/tsconfigPackageResolver.test.ts +6 -24
- package/transforms/useClientBundlePlugin.ts +2 -22
- package/typeChecker.ts +7 -63
- package/uploadRelease.ts +10 -25
- package/workflow/artifacts.ts +18 -0
- package/workflow/executor.test.ts +1 -12
- package/workflow/executor.ts +85 -146
- 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/source.ts +33 -56
- package/workflow/sourceAst.ts +50 -0
- package/workspaceLayout.ts +4 -19
- package/commandDecorators/commandMeta.ts +0 -7
- package/commandDecorators/types.ts +0 -10
- package/getDirname.ts +0 -4
- package/local/optimize-resource/ipcprobe/child.ts +0 -1
- package/local/optimize-resource/ipcprobe/parent.ts +0 -11
- package/workflow/primitive.ts +0 -59
|
@@ -3,23 +3,10 @@ import type { BaseBuildArtifact, CssPayload, PagesBundlePayload } from "akanjs/s
|
|
|
3
3
|
|
|
4
4
|
export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
* `base` is the boot build (`SsrBaseArtifactBuilder`) and is never batched with the others: it is what a
|
|
8
|
-
* builder runs before it can serve anything, where the rest are per-save work. It earns its place here
|
|
9
|
-
* for the same reason as the others — measured on `apps/akan`, it retains **+1143 MB** of bundler arena
|
|
10
|
-
* that only a process exit returns, which was 65 % of the builder's whole idle footprint.
|
|
11
|
-
*/
|
|
6
|
+
// `base` is the boot build: it always runs alone, before the builder can serve anything.
|
|
12
7
|
export type BuildBatchNeed = "base" | "pages" | "css" | "csr";
|
|
13
8
|
|
|
14
|
-
|
|
15
|
-
* One generation of build work, handed to a process that exits when it is done.
|
|
16
|
-
*
|
|
17
|
-
* Everything here is JSON: the worker is spawned per batch, so state that the long-lived watcher
|
|
18
|
-
* caches for the session has to travel by value. Two fields exist purely to keep behaviour identical
|
|
19
|
-
* to the in-process version — `pageKeys` because rediscovering them costs ~220ms on a 177-route app
|
|
20
|
-
* (route-export validation), and `optimizedFonts` because fonts are only re-optimized when a font file
|
|
21
|
-
* actually changed.
|
|
22
|
-
*/
|
|
9
|
+
// JSON only: the worker is spawned per batch and receives this in its argv.
|
|
23
10
|
export interface BuildBatchRequest {
|
|
24
11
|
appName: string;
|
|
25
12
|
workspaceRoot: string;
|
|
@@ -27,37 +14,22 @@ export interface BuildBatchRequest {
|
|
|
27
14
|
generation: number;
|
|
28
15
|
needs: BuildBatchNeed[];
|
|
29
16
|
changedFiles: string[];
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
optimizedFonts: OptimizedFonts | null;
|
|
34
|
-
/** Previous css assets, so an unchanged compile skips the broadcast instead of busting hashes. */
|
|
35
|
-
cssAssets: PagesBatchCssAssets | null;
|
|
36
|
-
/** Absolute artifact directory; the worker writes css assets under it. */
|
|
17
|
+
pageKeys: string[] | null; // null: the worker discovers them itself
|
|
18
|
+
optimizedFonts: OptimizedFonts | null; // reused unless this batch touched one of its files
|
|
19
|
+
cssAssets: PagesBatchCssAssets | null; // an unchanged compile skips the broadcast instead of busting hashes
|
|
37
20
|
artifactDir: string;
|
|
38
21
|
}
|
|
39
22
|
|
|
40
23
|
export type PagesBatchCssAssets = CssPayload["cssAssets"];
|
|
41
24
|
|
|
42
|
-
|
|
43
|
-
* What the watcher folds back into its own state once the worker is done.
|
|
44
|
-
*
|
|
45
|
-
* Deliberately small: the payloads browsers are waiting on are *streamed* as each need finishes
|
|
46
|
-
* (`pages-updated`, `css-updated`, `build-status`, relayed straight through), so a page reload is not
|
|
47
|
-
* held back by a css compile that has not started yet. Only state the next batch needs travels here.
|
|
48
|
-
*/
|
|
25
|
+
// Browser payloads stream as each need finishes; only state the next batch needs travels back here.
|
|
49
26
|
export interface BuildBatchResult {
|
|
50
27
|
generation: number;
|
|
51
28
|
cssAssets?: PagesBatchCssAssets;
|
|
52
29
|
optimizedFonts?: OptimizedFonts;
|
|
53
|
-
|
|
54
|
-
* Present only for a `base` batch. The same object the worker wrote to `base-artifact.json`, returned
|
|
55
|
-
* here so the watcher does not have to read its own boot build back off disk.
|
|
56
|
-
*/
|
|
57
|
-
artifact?: BaseBuildArtifact;
|
|
30
|
+
artifact?: BaseBuildArtifact; // `base` batches only
|
|
58
31
|
errors: Partial<Record<BuildBatchNeed, string>>;
|
|
59
|
-
|
|
60
|
-
crashed?: boolean;
|
|
32
|
+
crashed?: boolean; // died before reporting, so it streamed no build-status for these needs
|
|
61
33
|
}
|
|
62
34
|
|
|
63
35
|
export type BuildBatchMessage = { type: "build-batch-result"; data: BuildBatchResult };
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createTempApp, tempRoots, writeText } from "../testHelpers";
|
|
4
|
+
import { BuildBatchRunner } from "./buildBatchRunner";
|
|
5
|
+
|
|
6
|
+
const track = tempRoots();
|
|
7
|
+
|
|
8
|
+
describe("BuildBatchRunner", () => {
|
|
9
|
+
test("reports a boot build the bundler refuses with where each reason sits in the workspace", async () => {
|
|
10
|
+
// Inside the checkout so `akanjs/server` resolves to its source: from the OS temp dir Bun auto-installs npm's 2.x.
|
|
11
|
+
const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
|
|
12
|
+
const appDir = path.join(root, "apps/demo");
|
|
13
|
+
await writeText(path.join(appDir, "env/env.client.ts"), "export const env = {} as const;\n");
|
|
14
|
+
await writeText(
|
|
15
|
+
path.join(appDir, "page/_index.tsx"),
|
|
16
|
+
'import { gone } from "./not-there";\nexport default gone;\n',
|
|
17
|
+
);
|
|
18
|
+
|
|
19
|
+
const result = await new BuildBatchRunner({ workspaceRoot: root, cwd: appDir }).run({
|
|
20
|
+
appName: "demo",
|
|
21
|
+
workspaceRoot: root,
|
|
22
|
+
repoName: "repo",
|
|
23
|
+
generation: 0,
|
|
24
|
+
needs: ["base"],
|
|
25
|
+
changedFiles: [],
|
|
26
|
+
pageKeys: null,
|
|
27
|
+
optimizedFonts: null,
|
|
28
|
+
cssAssets: null,
|
|
29
|
+
artifactDir: path.join(appDir, ".akan/artifact"),
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
expect(result.errors.base).toContain('"./not-there" (apps/demo/page/_index.tsx:1:22)');
|
|
33
|
+
}, 30_000);
|
|
34
|
+
});
|
|
@@ -3,25 +3,9 @@ import { Logger } from "akanjs/common";
|
|
|
3
3
|
import type { BuilderMessage } from "akanjs/server";
|
|
4
4
|
import type { BuildBatchMessage, BuildBatchRequest, BuildBatchResult } from "./buildBatchProtocol";
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
* The watcher serializes every batch through its own work queue, so this deliberately has no pool: one
|
|
10
|
-
* worker exists at a time, and it exits before the next one starts. That is the whole point — the
|
|
11
|
-
* bundler arenas `Bun.build` never frees go back to the OS with the process.
|
|
12
|
-
*
|
|
13
|
-
* A worker that dies without reporting is not fatal. Every need it was given comes back as an error, the
|
|
14
|
-
* watcher reports a red build-status for that generation, and the last-good artifact keeps serving —
|
|
15
|
-
* the same contract a failed in-process build had. It must never take the watcher down with it: the
|
|
16
|
-
* watcher is the dev server's file watcher, so nothing would notice the fix.
|
|
17
|
-
*/
|
|
6
|
+
// No pool on purpose: one worker at a time, exiting after its batch, returns the arenas `Bun.build` never frees.
|
|
7
|
+
// A worker that dies without reporting must not take the watcher down; its needs come back as errors.
|
|
18
8
|
export class BuildBatchRunner {
|
|
19
|
-
static readonly #entryCandidates = (workspaceRoot: string) => [
|
|
20
|
-
path.join(workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
21
|
-
path.join(workspaceRoot, "node_modules/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
22
|
-
path.join(import.meta.dir, "buildBatch.proc.js"),
|
|
23
|
-
path.join(import.meta.dir, "buildBatch.proc.ts"),
|
|
24
|
-
];
|
|
25
9
|
#logger = new Logger("BuildBatchRunner");
|
|
26
10
|
#entry: string | null = null;
|
|
27
11
|
#workspaceRoot: string;
|
|
@@ -33,7 +17,12 @@ export class BuildBatchRunner {
|
|
|
33
17
|
|
|
34
18
|
async #resolveEntry(): Promise<string> {
|
|
35
19
|
if (this.#entry) return this.#entry;
|
|
36
|
-
const candidates =
|
|
20
|
+
const candidates = [
|
|
21
|
+
path.join(this.#workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
22
|
+
path.join(this.#workspaceRoot, "node_modules/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
|
|
23
|
+
path.join(import.meta.dir, "buildBatch.proc.js"),
|
|
24
|
+
path.join(import.meta.dir, "buildBatch.proc.ts"),
|
|
25
|
+
];
|
|
37
26
|
for (const candidate of candidates) {
|
|
38
27
|
if (!(await Bun.file(candidate).exists())) continue;
|
|
39
28
|
this.#entry = candidate;
|
|
@@ -42,11 +31,6 @@ export class BuildBatchRunner {
|
|
|
42
31
|
throw new Error(`[build-batch] worker entry not found; looked in: ${candidates.join(", ")}`);
|
|
43
32
|
}
|
|
44
33
|
|
|
45
|
-
/**
|
|
46
|
-
* `onMessage` receives everything the worker streams as it goes — `pages-updated`, `css-updated`,
|
|
47
|
-
* `build-status` — so the watcher can relay each one the moment it is produced instead of holding a
|
|
48
|
-
* page reload until the whole batch is done.
|
|
49
|
-
*/
|
|
50
34
|
async run(
|
|
51
35
|
request: BuildBatchRequest,
|
|
52
36
|
onMessage: (message: BuilderMessage) => void = () => undefined,
|
|
@@ -54,8 +38,7 @@ export class BuildBatchRunner {
|
|
|
54
38
|
const started = Date.now();
|
|
55
39
|
const entry = await this.#resolveEntry();
|
|
56
40
|
let result: BuildBatchResult | null = null;
|
|
57
|
-
//
|
|
58
|
-
// instead of waiting for a handshake it would have to synchronize against.
|
|
41
|
+
// argv rather than IPC, so the worker starts on its first tick instead of waiting for a handshake.
|
|
59
42
|
const proc = Bun.spawn(["bun", entry, JSON.stringify(request)], {
|
|
60
43
|
cwd: this.#cwd,
|
|
61
44
|
env: process.env,
|
|
@@ -74,11 +57,7 @@ export class BuildBatchRunner {
|
|
|
74
57
|
);
|
|
75
58
|
return result;
|
|
76
59
|
}
|
|
77
|
-
//
|
|
78
|
-
// reads exactly like an ordinary crash — and the two have opposite fixes: one is a build error to
|
|
79
|
-
// find, the other is a memory limit to raise. The peak here is the largest transient in the tree
|
|
80
|
-
// (a boot build measured 548MB on a tenant app, 1.1GB on apps/akan), so on a small sandbox this is
|
|
81
|
-
// the process the kernel reaches for first.
|
|
60
|
+
// An OOM kill (code null, SIGKILL) reads like a crash without the signal, and needs the opposite fix.
|
|
82
61
|
const message = proc.signalCode
|
|
83
62
|
? `build worker was killed by ${proc.signalCode} before reporting a result${
|
|
84
63
|
proc.signalCode === "SIGKILL" ? " — most often the kernel OOM killer; check the sandbox's memory limit" : ""
|
|
@@ -1,30 +1,18 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
3
|
-
import os from "node:os";
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
4
2
|
import path from "node:path";
|
|
3
|
+
import { tempDirs } from "../testHelpers";
|
|
5
4
|
import { BuilderChannel } from "./builderChannel";
|
|
6
5
|
|
|
7
|
-
const
|
|
8
|
-
|
|
9
|
-
afterEach(async () => {
|
|
10
|
-
for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true });
|
|
11
|
-
});
|
|
6
|
+
const tempDir = tempDirs("builder-channel-");
|
|
12
7
|
|
|
13
8
|
type SendMode = "drain" | "await" | "bare";
|
|
14
9
|
|
|
15
|
-
|
|
16
|
-
* Run a child that sends what the builder sends and then exits the way the recycle drain does, and
|
|
17
|
-
* report what the parent actually received.
|
|
18
|
-
*
|
|
19
|
-
* `payload` picks the message shape, because shape decides whether a bare send survives: `manifest` is a
|
|
20
|
-
* map of many short strings (a `build-route-res` delta), `css` is one long string (a base64 stylesheet).
|
|
21
|
-
*/
|
|
10
|
+
// Shape decides whether a bare send survives: `manifest` is many short strings, `css` one long string.
|
|
22
11
|
const sendThenExit = async (
|
|
23
12
|
bytes: number,
|
|
24
13
|
{ mode, payload }: { mode: SendMode; payload: "manifest" | "css" },
|
|
25
14
|
): Promise<Array<{ type?: string }>> => {
|
|
26
|
-
const dir = await
|
|
27
|
-
tempDirs.push(dir);
|
|
15
|
+
const dir = await tempDir();
|
|
28
16
|
const entry = path.join(dir, "child.ts");
|
|
29
17
|
const modulePath = JSON.stringify(path.join(import.meta.dir, "builderChannel"));
|
|
30
18
|
const build =
|
|
@@ -63,44 +51,42 @@ const sendThenExit = async (
|
|
|
63
51
|
return received;
|
|
64
52
|
};
|
|
65
53
|
|
|
54
|
+
const withDeferredFlushes = async (fn: (flushes: Array<() => void>) => Promise<void>) => {
|
|
55
|
+
const send = process.send;
|
|
56
|
+
const flushes: Array<() => void> = [];
|
|
57
|
+
try {
|
|
58
|
+
(process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
|
|
59
|
+
flushes.push(cb);
|
|
60
|
+
return true;
|
|
61
|
+
};
|
|
62
|
+
await fn(flushes);
|
|
63
|
+
} finally {
|
|
64
|
+
(process as { send?: unknown }).send = send;
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
|
|
66
68
|
describe("BuilderChannel", () => {
|
|
67
69
|
test("delivers a reply too large for the pipe buffer before the process exits", async () => {
|
|
68
70
|
expect(await sendThenExit(1_000_000, { mode: "await", payload: "manifest" })).toMatchObject([{ id: 7 }]);
|
|
69
|
-
// A manifest delta is usually well past the buffer, but the small case must keep working too.
|
|
70
71
|
expect(await sendThenExit(0, { mode: "await", payload: "manifest" })).toMatchObject([{ id: 7 }]);
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
test("a drained event survives the exit even though nobody awaited it", async () => {
|
|
74
|
-
// The event path: `css-updated` is relayed with no caller to await it, and the recycle drain exits
|
|
75
|
-
// milliseconds later. Only `drain()` stands between the two.
|
|
76
75
|
expect(await sendThenExit(200_000, { mode: "drain", payload: "css" })).toMatchObject([{ type: "css-updated" }]);
|
|
77
76
|
});
|
|
78
77
|
|
|
79
|
-
|
|
80
|
-
* darwin only, measured: on Linux a 1MB message sent immediately before `process.exit` **arrives in
|
|
81
|
-
* full**, so the loss this control demonstrates does not reproduce there and the assertion would fail
|
|
82
|
-
* for the right reason on the wrong platform. `BuilderChannel` itself stays correct and costs nothing
|
|
83
|
-
* on Linux — the bug it prevents is one macOS developers hit and a Linux fleet does not.
|
|
84
|
-
*/
|
|
78
|
+
// darwin only: on Linux a message sent right before `process.exit` arrives in full, so the loss does not reproduce.
|
|
85
79
|
test.skipIf(process.platform !== "darwin")(
|
|
86
80
|
"without the flush wait the same messages are lost, which is why this class exists",
|
|
87
81
|
async () => {
|
|
88
82
|
// Controls, not requirements: if a future bun flushes ipc writes on exit, these fail and say so.
|
|
89
83
|
expect(await sendThenExit(1_000_000, { mode: "bare", payload: "manifest" })).toEqual([]);
|
|
90
|
-
// 20KB of css, far below the 64KB the manifest shape survives — one long string dies much earlier,
|
|
91
|
-
// so no size threshold would have been safe to special-case.
|
|
92
84
|
expect(await sendThenExit(20_000, { mode: "bare", payload: "css" })).toEqual([]);
|
|
93
85
|
},
|
|
94
86
|
);
|
|
95
87
|
|
|
96
88
|
test("drain resolves only once every tracked send has flushed", async () => {
|
|
97
|
-
|
|
98
|
-
const flushes: Array<() => void> = [];
|
|
99
|
-
try {
|
|
100
|
-
(process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
|
|
101
|
-
flushes.push(cb);
|
|
102
|
-
return true;
|
|
103
|
-
};
|
|
89
|
+
await withDeferredFlushes(async (flushes) => {
|
|
104
90
|
BuilderChannel.emit({ type: "builder-ready" });
|
|
105
91
|
BuilderChannel.emit({ type: "builder-ready" });
|
|
106
92
|
let drained = false;
|
|
@@ -113,31 +99,20 @@ describe("BuilderChannel", () => {
|
|
|
113
99
|
expect(drained).toBe(false);
|
|
114
100
|
flushes[1]?.();
|
|
115
101
|
expect(await draining).toBe(2);
|
|
116
|
-
}
|
|
117
|
-
(process as { send?: unknown }).send = send;
|
|
118
|
-
}
|
|
102
|
+
});
|
|
119
103
|
});
|
|
120
104
|
|
|
121
105
|
test("a send started while draining is drained too", async () => {
|
|
122
|
-
|
|
123
|
-
const flushes: Array<() => void> = [];
|
|
124
|
-
try {
|
|
125
|
-
(process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
|
|
126
|
-
flushes.push(cb);
|
|
127
|
-
return true;
|
|
128
|
-
};
|
|
106
|
+
await withDeferredFlushes(async (flushes) => {
|
|
129
107
|
BuilderChannel.emit({ type: "builder-ready" });
|
|
130
108
|
const draining = BuilderChannel.drain();
|
|
131
|
-
// What `#reportMetrics` does from a work item's `finally`, after the drain has begun.
|
|
132
109
|
BuilderChannel.emit({ type: "builder-metrics", data: { rssBytes: 1, generation: 1, workCount: 1 } });
|
|
133
110
|
for (let i = 0; i < 4 && flushes.length; i++) {
|
|
134
111
|
flushes.shift()?.();
|
|
135
112
|
await Bun.sleep(1);
|
|
136
113
|
}
|
|
137
114
|
expect(await draining).toBe(2);
|
|
138
|
-
}
|
|
139
|
-
(process as { send?: unknown }).send = send;
|
|
140
|
-
}
|
|
115
|
+
});
|
|
141
116
|
});
|
|
142
117
|
|
|
143
118
|
test("resolves instead of hanging when there is no ipc channel", async () => {
|
|
@@ -1,42 +1,18 @@
|
|
|
1
1
|
import type { BuilderMessage } from "akanjs/server";
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
* the process.
|
|
6
|
-
*
|
|
7
|
-
* `process.exit` discards an ipc write that has not flushed, and this process exits routinely — the
|
|
8
|
-
* recycle that keeps its RSS under the ceiling ends in `process.exit(0)`. Whether a given message
|
|
9
|
-
* survives that depends on its *shape*, not only its size. Measured on bun 1.3.14 (darwin,
|
|
10
|
-
* `serialization: "advanced"`), send-then-exit-immediately, 20 rounds each:
|
|
11
|
-
*
|
|
12
|
-
* - one long string: 8KB delivered 20/20, 16KB delivered **0/20**
|
|
13
|
-
* - many short strings: 60KB delivered 20/20, 100KB delivered **0/20**
|
|
14
|
-
*
|
|
15
|
-
* `css-updated` is the first shape (a base64 stylesheet per url) and `build-route-res` the second (a
|
|
16
|
-
* manifest delta), so neither sits safely under a threshold worth relying on. Nothing here assumes one:
|
|
17
|
-
* every send reports when it flushed, and `drain` is what `shutdown` awaits before exiting.
|
|
18
|
-
*
|
|
19
|
-
* A natural exit does *not* have this problem — a process that simply returns from `main` flushes 1MB
|
|
20
|
-
* 20/20 — which is why the disposable build worker needs none of this. Only an explicit `process.exit`
|
|
21
|
-
* cuts a write short.
|
|
22
|
-
*/
|
|
3
|
+
// `process.exit` drops an ipc write that has not flushed (a natural exit does not), and whether one survives
|
|
4
|
+
// depends on the message's shape as well as its size — so every send reports its flush and `shutdown` drains.
|
|
23
5
|
export class BuilderChannel {
|
|
24
|
-
|
|
25
|
-
* Bound so a runtime that ever stops invoking the flush callback costs one late message instead of
|
|
26
|
-
* wedging the shutdown drain until the host's kill watchdog fires.
|
|
27
|
-
*/
|
|
6
|
+
// Bounded so a runtime that never calls the flush callback costs one late message, not a wedged drain.
|
|
28
7
|
static readonly #flushTimeoutMs = 5_000;
|
|
29
|
-
/** Sends that have not flushed yet; `drain` only ever needs to await them all. */
|
|
30
8
|
static readonly #flushing = new Set<Promise<void>>();
|
|
31
9
|
|
|
32
|
-
/** Send, and resolve once the write has left this process. */
|
|
33
10
|
static send(message: BuilderMessage): Promise<void> {
|
|
34
11
|
const send = process.send;
|
|
35
12
|
if (!send) return Promise.resolve();
|
|
36
13
|
const flushed = new Promise<void>((resolve) => {
|
|
37
14
|
const timer = setTimeout(resolve, BuilderChannel.#flushTimeoutMs);
|
|
38
|
-
//
|
|
39
|
-
// process exits, and a lost event is superseded by the next generation's.
|
|
15
|
+
// No retry: the host answers an orphaned request id on exit, and a lost event is superseded next generation.
|
|
40
16
|
send.call(process, message, undefined, undefined, () => {
|
|
41
17
|
clearTimeout(timer);
|
|
42
18
|
resolve();
|
|
@@ -47,19 +23,12 @@ export class BuilderChannel {
|
|
|
47
23
|
return flushed;
|
|
48
24
|
}
|
|
49
25
|
|
|
50
|
-
|
|
51
|
-
* Send an event no caller awaits. Tracked exactly like `send`, which is the whole point: a relayed
|
|
52
|
-
* `css-updated` has nobody to await it, so `drain` is the only thing standing between it and the exit.
|
|
53
|
-
*/
|
|
26
|
+
// Tracked like `send`: for an event nobody awaits, `drain` is all that stands between it and the exit.
|
|
54
27
|
static emit(message: BuilderMessage): void {
|
|
55
28
|
void BuilderChannel.send(message);
|
|
56
29
|
}
|
|
57
30
|
|
|
58
|
-
|
|
59
|
-
* Resolve once every send handed over so far has flushed, and report how many were still in flight.
|
|
60
|
-
* Sends started *while* draining are covered too — `#reportMetrics` can fire from a work item's
|
|
61
|
-
* `finally`, after the drain has already begun.
|
|
62
|
-
*/
|
|
31
|
+
// Loops because `#reportMetrics` can send from a work item's `finally` after the drain has begun.
|
|
63
32
|
static async drain(): Promise<number> {
|
|
64
33
|
let flushed = 0;
|
|
65
34
|
while (BuilderChannel.#flushing.size) {
|
|
@@ -10,7 +10,6 @@ const routeReq = (id: number, routeId: string): BuilderReq => ({
|
|
|
10
10
|
knownEntries: [],
|
|
11
11
|
});
|
|
12
12
|
|
|
13
|
-
/** `routeId` is what tells one generation's answer from the other's; the rest is an empty delta. */
|
|
14
13
|
const routeRes = (id: number, routeId: string): Extract<BuilderRes, { ok: true }> => ({
|
|
15
14
|
type: "build-route-res",
|
|
16
15
|
id,
|
|
@@ -23,7 +22,6 @@ describe("BuilderRequestRouter", () => {
|
|
|
23
22
|
const router = new BuilderRequestRouter();
|
|
24
23
|
router.startGeneration();
|
|
25
24
|
|
|
26
|
-
// Everything but the id travels untouched; the builder never learns it was renumbered.
|
|
27
25
|
const first = router.issue(routeReq(1, "/home"));
|
|
28
26
|
const second = router.issue(routeReq(2, "/about"));
|
|
29
27
|
expect(first.routeId).toBe("/home");
|
|
@@ -35,11 +33,6 @@ describe("BuilderRequestRouter", () => {
|
|
|
35
33
|
expect(router.inFlightCount).toBe(0);
|
|
36
34
|
});
|
|
37
35
|
|
|
38
|
-
/**
|
|
39
|
-
* The defect this class exists for. `BuilderRpc` numbers from 1 in every backend process, so without
|
|
40
|
-
* renumbering the second generation's `id: 1` request is settled by the first generation's answer —
|
|
41
|
-
* a page rendered against another route's client manifest — and its own answer is then dropped.
|
|
42
|
-
*/
|
|
43
36
|
test("does not deliver a dead generation's answer to the backend that replaced it", () => {
|
|
44
37
|
const router = new BuilderRequestRouter();
|
|
45
38
|
router.startGeneration();
|
|
@@ -70,7 +63,6 @@ describe("BuilderRequestRouter", () => {
|
|
|
70
63
|
router.startGeneration();
|
|
71
64
|
const outgoing = router.issue(routeReq(4, "/home"));
|
|
72
65
|
|
|
73
|
-
// The caller answers the backend itself in this case, so a late builder answer must not answer it again.
|
|
74
66
|
router.withdraw(outgoing.id);
|
|
75
67
|
expect(router.inFlightCount).toBe(0);
|
|
76
68
|
expect(router.settle(routeRes(outgoing.id, "/home"))).toBeNull();
|
|
@@ -3,19 +3,8 @@ import type { BuilderCsrReq, BuilderCsrRes, BuilderReq, BuilderRes } from "akanj
|
|
|
3
3
|
type BuilderRequest = BuilderReq | BuilderCsrReq;
|
|
4
4
|
type BuilderResponse = BuilderRes | BuilderCsrRes;
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
*
|
|
9
|
-
* `BuilderRpc` numbers its requests from 1 in each backend *process*, and the host relays them to a
|
|
10
|
-
* builder that outlives the backend. So after a restart the two generations collide on id 1: the builder
|
|
11
|
-
* answers the request the *previous* backend made, the host relays it, and the new backend settles its own
|
|
12
|
-
* request of that number with another route's manifest delta — a page rendered against client modules that
|
|
13
|
-
* were never built for it. The answer it was actually waiting for then arrives to an empty pending map and
|
|
14
|
-
* is dropped, so the correct build is discarded too.
|
|
15
|
-
*
|
|
16
|
-
* A host-owned id fixes it without touching the protocol: neither the backend nor the builder learns that
|
|
17
|
-
* anything was renumbered, and a response whose generation is gone is discarded instead of misdelivered.
|
|
18
|
-
*/
|
|
6
|
+
// `BuilderRpc` numbers from 1 per backend process while the builder outlives backends, so ids collide across a
|
|
7
|
+
// restart; host-owned ids keep a dead generation's answer from settling the new backend's request.
|
|
19
8
|
export class BuilderRequestRouter {
|
|
20
9
|
#generation = 0;
|
|
21
10
|
#nextId = 1;
|
|
@@ -29,33 +18,25 @@ export class BuilderRequestRouter {
|
|
|
29
18
|
return this.#inFlight.size;
|
|
30
19
|
}
|
|
31
20
|
|
|
32
|
-
|
|
33
|
-
* Begin a new backend generation, abandoning every request the previous one owned.
|
|
34
|
-
*
|
|
35
|
-
* Nothing is answered on the way out: the process that asked is gone, and its `BuilderRpc` went with it.
|
|
36
|
-
*/
|
|
21
|
+
// Abandons the previous generation's requests unanswered: the backend that asked is gone.
|
|
37
22
|
startGeneration(): number {
|
|
38
23
|
this.#generation += 1;
|
|
39
24
|
this.#inFlight.clear();
|
|
40
25
|
return this.#generation;
|
|
41
26
|
}
|
|
42
27
|
|
|
43
|
-
/** The request to forward to the builder, carrying this host's id in place of the backend's. */
|
|
44
28
|
issue<T extends BuilderRequest>(message: T): T {
|
|
45
29
|
const id = this.#nextId++;
|
|
46
30
|
this.#inFlight.set(id, { backendId: message.id, generation: this.#generation });
|
|
47
31
|
return { ...message, id };
|
|
48
32
|
}
|
|
49
33
|
|
|
50
|
-
|
|
34
|
+
// For a failed send, after which the caller answers the backend itself.
|
|
51
35
|
withdraw(id: number): void {
|
|
52
36
|
this.#inFlight.delete(id);
|
|
53
37
|
}
|
|
54
38
|
|
|
55
|
-
|
|
56
|
-
* The response to forward to the backend with its own id restored, or `null` when no live backend is
|
|
57
|
-
* waiting for it — either it was never issued or the generation that issued it has been replaced.
|
|
58
|
-
*/
|
|
39
|
+
// null when no live backend is waiting: never issued, or its generation was replaced.
|
|
59
40
|
settle<T extends BuilderResponse>(message: T): T | null {
|
|
60
41
|
const request = this.#inFlight.get(message.id);
|
|
61
42
|
if (!request) return null;
|
|
@@ -1,19 +1,22 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
import path from "node:path";
|
|
3
|
-
import { DevChangePlanner } from "../frontendBuild";
|
|
3
|
+
import { DevChangePlanner, type GeneratedIndexSyncResult } from "../frontendBuild";
|
|
4
4
|
import { prepareDevWatchBatch } from "./devWatchBatch";
|
|
5
5
|
|
|
6
|
+
const prepare = (root: string, generation: number, file: string, indexSync: GeneratedIndexSyncResult) =>
|
|
7
|
+
prepareDevWatchBatch({
|
|
8
|
+
generation,
|
|
9
|
+
batch: { files: [file], kinds: new Set(["code"]) },
|
|
10
|
+
indexSync,
|
|
11
|
+
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
12
|
+
});
|
|
13
|
+
|
|
6
14
|
describe("prepareDevWatchBatch", () => {
|
|
7
15
|
test("includes generated indexes in the same invalidate generation", () => {
|
|
8
16
|
const root = path.resolve("/repo");
|
|
9
17
|
const changedFile = path.join(root, "libs/shared/common/foo.ts");
|
|
10
18
|
const generatedIndex = path.join(root, "libs/shared/common/index.ts");
|
|
11
|
-
const prepared =
|
|
12
|
-
generation: 12,
|
|
13
|
-
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
|
14
|
-
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
15
|
-
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
16
|
-
});
|
|
19
|
+
const prepared = prepare(root, 12, changedFile, { changedFiles: [generatedIndex], errors: [] });
|
|
17
20
|
|
|
18
21
|
expect(prepared.hasSyncErrors).toBe(false);
|
|
19
22
|
expect(prepared.files).toEqual([changedFile, generatedIndex]);
|
|
@@ -29,12 +32,7 @@ describe("prepareDevWatchBatch", () => {
|
|
|
29
32
|
const root = path.resolve("/repo");
|
|
30
33
|
const changedFile = path.join(root, "libs/shared", facet, "tmpExample.ts");
|
|
31
34
|
const generatedIndex = path.join(root, "libs/shared", facet, "index.ts");
|
|
32
|
-
const prepared =
|
|
33
|
-
generation: 20,
|
|
34
|
-
batch: { files: [changedFile], kinds: new Set(["code"]) },
|
|
35
|
-
indexSync: { changedFiles: [generatedIndex], errors: [] },
|
|
36
|
-
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
37
|
-
});
|
|
35
|
+
const prepared = prepare(root, 20, changedFile, { changedFiles: [generatedIndex], errors: [] });
|
|
38
36
|
|
|
39
37
|
expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
|
|
40
38
|
expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
|
|
@@ -45,11 +43,9 @@ describe("prepareDevWatchBatch", () => {
|
|
|
45
43
|
|
|
46
44
|
test("marks failed generated index sync as an error generation", () => {
|
|
47
45
|
const root = "/repo";
|
|
48
|
-
const prepared =
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
indexSync: { changedFiles: [], errors: ["sync failed"] },
|
|
52
|
-
changePlanner: new DevChangePlanner({ workspaceRoot: root }),
|
|
46
|
+
const prepared = prepare(root, 13, `${root}/libs/shared/common/foo.ts`, {
|
|
47
|
+
changedFiles: [],
|
|
48
|
+
errors: ["sync failed"],
|
|
53
49
|
});
|
|
54
50
|
|
|
55
51
|
expect(prepared.hasSyncErrors).toBe(true);
|