@akanjs/devkit 1.0.19 → 2.1.0-rc.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/aiEditor.ts +304 -0
- package/akanApp/akanApp.host.ts +393 -0
- package/akanApp/index.ts +1 -0
- package/akanConfig/akanConfig.test.ts +236 -0
- package/akanConfig/akanConfig.ts +384 -0
- package/akanConfig/index.ts +2 -0
- package/akanConfig/types.ts +23 -0
- package/applicationBuildReporter.ts +69 -0
- package/applicationBuildRunner.ts +302 -0
- package/applicationReleasePackager.ts +206 -0
- package/artifact/implicitRootLayout.ts +155 -0
- package/artifact/index.ts +1 -0
- package/artifact/routeSeedIndex.test.ts +98 -0
- package/artifact/routeSeedIndex.ts +130 -0
- package/auth.ts +41 -0
- package/builder.ts +164 -0
- package/capacitor.base.config.ts +88 -0
- package/capacitorApp.ts +440 -0
- package/commandDecorators/argMeta.ts +102 -0
- package/commandDecorators/command.ts +343 -0
- package/commandDecorators/commandBuilder.ts +224 -0
- package/commandDecorators/commandDecorators.test.ts +212 -0
- package/commandDecorators/commandMeta.ts +7 -0
- package/commandDecorators/dependencyBuilder.ts +100 -0
- package/{esm/src/commandDecorators/helpFormatter.js → commandDecorators/helpFormatter.ts} +100 -47
- package/{esm/src/commandDecorators/index.js → commandDecorators/index.ts} +4 -2
- package/commandDecorators/targetMeta.ts +31 -0
- package/commandDecorators/types.ts +10 -0
- package/constants.ts +25 -0
- package/createTunnel.ts +36 -0
- package/dependencyScanner.ts +357 -0
- package/devkitUtils.test.ts +259 -0
- package/executors.test.ts +315 -0
- package/executors.ts +1390 -0
- package/{esm/src/extractDeps.js → extractDeps.ts} +26 -20
- package/{esm/src/fileEditor.js → fileEditor.ts} +51 -32
- package/fileSys.ts +39 -0
- package/frontendBuild/allRoutesBuilder.ts +103 -0
- package/frontendBuild/buildRouteClient.test.ts +190 -0
- package/frontendBuild/clientBuildTypes.ts +114 -0
- package/frontendBuild/clientEntriesBundler.ts +303 -0
- package/frontendBuild/clientEntryDiscovery.ts +199 -0
- package/frontendBuild/csrArtifactBuilder.ts +237 -0
- package/frontendBuild/cssCompiler.ts +286 -0
- package/frontendBuild/cssImportResolver.ts +116 -0
- package/frontendBuild/fontOptimizer.ts +427 -0
- package/frontendBuild/frontendBuild.test.ts +204 -0
- package/frontendBuild/hmrChangeClassifier.ts +28 -0
- package/frontendBuild/hmrWatcher.ts +102 -0
- package/frontendBuild/index.ts +18 -0
- package/frontendBuild/pagesBundleBuilder.ts +137 -0
- package/frontendBuild/pagesEntrySourceGenerator.ts +37 -0
- package/frontendBuild/precompressArtifacts.ts +59 -0
- package/frontendBuild/routeClientBuilder.ts +290 -0
- package/frontendBuild/routesManifestArtifactSerializer.ts +62 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +139 -0
- package/frontendBuild/vendorSpecifiers.ts +16 -0
- package/frontendBuild/watchRootResolver.ts +28 -0
- package/getCredentials.ts +19 -0
- package/getDirname.ts +3 -0
- package/getModelFileData.ts +59 -0
- package/getRelatedCnsts.ts +313 -0
- package/guideline.ts +19 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +51 -0
- package/incrementalBuilder/incrementalBuilder.host.ts +152 -0
- package/incrementalBuilder/incrementalBuilder.proc.ts +331 -0
- package/incrementalBuilder/index.ts +1 -0
- package/{esm/src/index.js → index.ts} +28 -15
- package/lint/no-deep-internal-import.grit +25 -0
- package/lint/no-import-client-functions.grit +32 -0
- package/lint/no-import-external-library.grit +21 -0
- package/lint/no-js-private-class-method.grit +42 -0
- package/lint/no-use-client-in-server.grit +7 -0
- package/lint/non-scalar-props-restricted.grit +13 -0
- package/linter.ts +271 -0
- package/mobile/index.ts +1 -0
- package/mobile/mobileTarget.test.ts +53 -0
- package/mobile/mobileTarget.ts +88 -0
- package/package.json +48 -31
- package/prompter.ts +72 -0
- package/scanInfo.ts +606 -0
- package/selectModel.ts +11 -0
- package/{esm/src/spinner.js → spinner.ts} +22 -28
- package/{esm/src/capacitorApp.js → src/capacitorApp.ts} +82 -81
- package/sshTunnel.ts +152 -0
- package/{esm/src/streamAi.js → streamAi.ts} +18 -12
- package/transforms/barrelAnalyzer.ts +278 -0
- package/transforms/barrelImportsPlugin.ts +504 -0
- package/transforms/externalizeFrameworkPlugin.ts +185 -0
- package/transforms/index.ts +5 -0
- package/transforms/rscUseClientTransform.ts +59 -0
- package/transforms/transforms.test.ts +208 -0
- package/transforms/useClientBundlePlugin.ts +47 -0
- package/tsconfig.json +37 -0
- package/typeChecker.ts +264 -0
- package/types.ts +44 -0
- package/ui/MultiScrollList.tsx +242 -0
- package/ui/ScrollList.tsx +107 -0
- package/ui/index.ts +2 -0
- package/{esm/src/uploadRelease.js → uploadRelease.ts} +50 -34
- package/{esm/src/useStdoutDimensions.js → useStdoutDimensions.ts} +5 -5
- package/README.md +0 -1
- package/cjs/index.js +0 -21
- package/cjs/src/aiEditor.js +0 -311
- package/cjs/src/auth.js +0 -72
- package/cjs/src/builder.js +0 -114
- package/cjs/src/capacitorApp.js +0 -313
- package/cjs/src/commandDecorators/argMeta.js +0 -88
- package/cjs/src/commandDecorators/command.js +0 -324
- package/cjs/src/commandDecorators/commandMeta.js +0 -30
- package/cjs/src/commandDecorators/helpFormatter.js +0 -211
- package/cjs/src/commandDecorators/index.js +0 -31
- package/cjs/src/commandDecorators/targetMeta.js +0 -57
- package/cjs/src/commandDecorators/types.js +0 -15
- package/cjs/src/constants.js +0 -46
- package/cjs/src/createTunnel.js +0 -49
- package/cjs/src/dependencyScanner.js +0 -220
- package/cjs/src/executors.js +0 -964
- package/cjs/src/extractDeps.js +0 -103
- package/cjs/src/fileEditor.js +0 -120
- package/cjs/src/getCredentials.js +0 -44
- package/cjs/src/getDirname.js +0 -38
- package/cjs/src/getModelFileData.js +0 -66
- package/cjs/src/getRelatedCnsts.js +0 -260
- package/cjs/src/guideline.js +0 -15
- package/cjs/src/index.js +0 -65
- package/cjs/src/linter.js +0 -238
- package/cjs/src/prompter.js +0 -85
- package/cjs/src/scanInfo.js +0 -491
- package/cjs/src/selectModel.js +0 -46
- package/cjs/src/spinner.js +0 -93
- package/cjs/src/streamAi.js +0 -62
- package/cjs/src/typeChecker.js +0 -207
- package/cjs/src/types.js +0 -15
- package/cjs/src/uploadRelease.js +0 -112
- package/cjs/src/useStdoutDimensions.js +0 -43
- package/esm/index.js +0 -1
- package/esm/src/aiEditor.js +0 -282
- package/esm/src/auth.js +0 -42
- package/esm/src/builder.js +0 -81
- package/esm/src/commandDecorators/argMeta.js +0 -54
- package/esm/src/commandDecorators/command.js +0 -290
- package/esm/src/commandDecorators/commandMeta.js +0 -7
- package/esm/src/commandDecorators/targetMeta.js +0 -33
- package/esm/src/commandDecorators/types.js +0 -0
- package/esm/src/constants.js +0 -17
- package/esm/src/createTunnel.js +0 -26
- package/esm/src/dependencyScanner.js +0 -187
- package/esm/src/executors.js +0 -928
- package/esm/src/getCredentials.js +0 -11
- package/esm/src/getDirname.js +0 -5
- package/esm/src/getModelFileData.js +0 -33
- package/esm/src/getRelatedCnsts.js +0 -221
- package/esm/src/guideline.js +0 -0
- package/esm/src/linter.js +0 -205
- package/esm/src/prompter.js +0 -51
- package/esm/src/scanInfo.js +0 -455
- package/esm/src/selectModel.js +0 -13
- package/esm/src/typeChecker.js +0 -174
- package/esm/src/types.js +0 -0
- package/index.d.ts +0 -1
- package/src/aiEditor.d.ts +0 -50
- package/src/auth.d.ts +0 -9
- package/src/builder.d.ts +0 -18
- package/src/capacitorApp.d.ts +0 -39
- package/src/commandDecorators/argMeta.d.ts +0 -67
- package/src/commandDecorators/command.d.ts +0 -2
- package/src/commandDecorators/commandMeta.d.ts +0 -2
- package/src/commandDecorators/helpFormatter.d.ts +0 -3
- package/src/commandDecorators/index.d.ts +0 -6
- package/src/commandDecorators/targetMeta.d.ts +0 -19
- package/src/commandDecorators/types.d.ts +0 -1
- package/src/constants.d.ts +0 -26
- package/src/createTunnel.d.ts +0 -8
- package/src/dependencyScanner.d.ts +0 -23
- package/src/executors.d.ts +0 -296
- package/src/extractDeps.d.ts +0 -7
- package/src/fileEditor.d.ts +0 -16
- package/src/getCredentials.d.ts +0 -12
- package/src/getDirname.d.ts +0 -1
- package/src/getModelFileData.d.ts +0 -16
- package/src/getRelatedCnsts.d.ts +0 -53
- package/src/guideline.d.ts +0 -19
- package/src/index.d.ts +0 -23
- package/src/linter.d.ts +0 -109
- package/src/prompter.d.ts +0 -14
- package/src/scanInfo.d.ts +0 -82
- package/src/selectModel.d.ts +0 -1
- package/src/spinner.d.ts +0 -20
- package/src/streamAi.d.ts +0 -6
- package/src/typeChecker.d.ts +0 -52
- package/src/types.d.ts +0 -31
- package/src/uploadRelease.d.ts +0 -10
- package/src/useStdoutDimensions.d.ts +0 -1
package/aiEditor.ts
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { input, select } from "@inquirer/prompts";
|
|
2
|
+
import {
|
|
3
|
+
AIMessage,
|
|
4
|
+
type BaseMessage,
|
|
5
|
+
HumanMessage,
|
|
6
|
+
mapChatMessagesToStoredMessages,
|
|
7
|
+
mapStoredMessagesToChatMessages,
|
|
8
|
+
type StoredMessage,
|
|
9
|
+
} from "@langchain/core/messages";
|
|
10
|
+
import { ChatDeepSeek } from "@langchain/deepseek";
|
|
11
|
+
import { ChatOpenAI } from "@langchain/openai";
|
|
12
|
+
import { Logger } from "akanjs/common";
|
|
13
|
+
import chalk from "chalk";
|
|
14
|
+
|
|
15
|
+
import { getAkanGlobalConfig, setAkanGlobalConfig } from "./auth";
|
|
16
|
+
import type { Executor, WorkspaceExecutor } from "./executors";
|
|
17
|
+
import { Spinner } from "./spinner";
|
|
18
|
+
import type { FileContent } from "./types";
|
|
19
|
+
|
|
20
|
+
const MAX_ASK_TRY = 300;
|
|
21
|
+
|
|
22
|
+
export const supportedLlmModels = ["deepseek-chat", "deepseek-reasoner"] as const;
|
|
23
|
+
export type SupportedLlmModel = (typeof supportedLlmModels)[number];
|
|
24
|
+
|
|
25
|
+
interface EditOptions {
|
|
26
|
+
onReasoning?: (reasoning: string) => void;
|
|
27
|
+
onChunk?: (chunk: string) => void;
|
|
28
|
+
maxTry?: number;
|
|
29
|
+
validate?: string[];
|
|
30
|
+
approve?: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class AiSession {
|
|
34
|
+
static #cacheDir = "node_modules/.cache/akan/aiSession";
|
|
35
|
+
static #chat: ChatDeepSeek | ChatOpenAI | null = null;
|
|
36
|
+
static async init({ temperature = 0, useExisting = true }: { temperature?: number; useExisting?: boolean } = {}) {
|
|
37
|
+
if (useExisting) {
|
|
38
|
+
const llmConfig = await AiSession.getLlmConfig();
|
|
39
|
+
if (llmConfig) {
|
|
40
|
+
AiSession.#setChatModel(llmConfig.model, llmConfig.apiKey);
|
|
41
|
+
Logger.rawLog(chalk.dim(`🤖akan editor uses existing LLM config (${llmConfig.model})`));
|
|
42
|
+
return AiSession;
|
|
43
|
+
}
|
|
44
|
+
} else Logger.rawLog(chalk.yellow("🤖akan-editor is not initialized. LLM configuration should be set first."));
|
|
45
|
+
|
|
46
|
+
const llmConfig = await AiSession.#requestLlmConfig();
|
|
47
|
+
const { model, apiKey } = llmConfig;
|
|
48
|
+
|
|
49
|
+
await AiSession.#validateApiKey(model, apiKey);
|
|
50
|
+
const session = AiSession.#setChatModel(model, apiKey, { temperature });
|
|
51
|
+
await session.setLlmConfig({ model, apiKey });
|
|
52
|
+
return session;
|
|
53
|
+
}
|
|
54
|
+
static #setChatModel(model: SupportedLlmModel, apiKey: string, { temperature = 0 }: { temperature?: number } = {}) {
|
|
55
|
+
AiSession.#chat = new ChatDeepSeek({
|
|
56
|
+
modelName: model,
|
|
57
|
+
temperature,
|
|
58
|
+
streaming: true,
|
|
59
|
+
apiKey,
|
|
60
|
+
// configuration: { baseURL: "https://api.deepseek.com/v1", apiKey },
|
|
61
|
+
});
|
|
62
|
+
return AiSession;
|
|
63
|
+
}
|
|
64
|
+
static async getLlmConfig() {
|
|
65
|
+
const akanConfig = await getAkanGlobalConfig();
|
|
66
|
+
return akanConfig.llm ?? null;
|
|
67
|
+
}
|
|
68
|
+
static async setLlmConfig(llmConfig: { model: SupportedLlmModel; apiKey: string } | null) {
|
|
69
|
+
const akanConfig = await getAkanGlobalConfig();
|
|
70
|
+
akanConfig.llm = llmConfig;
|
|
71
|
+
await setAkanGlobalConfig(akanConfig);
|
|
72
|
+
return AiSession;
|
|
73
|
+
}
|
|
74
|
+
static async #requestLlmConfig() {
|
|
75
|
+
const model = await select<SupportedLlmModel>({ message: "Select a LLM model", choices: supportedLlmModels });
|
|
76
|
+
const apiKey = await input({ message: "Enter your API key" });
|
|
77
|
+
return { model, apiKey };
|
|
78
|
+
}
|
|
79
|
+
static async #validateApiKey(modelName: SupportedLlmModel, apiKey: string) {
|
|
80
|
+
const spinner = new Spinner("Validating LLM API key...", { prefix: `🤖akan-editor` }).start();
|
|
81
|
+
const chat = new ChatOpenAI({
|
|
82
|
+
modelName,
|
|
83
|
+
temperature: 0,
|
|
84
|
+
configuration: { baseURL: "https://api.deepseek.com/v1", apiKey },
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await chat.invoke("Hi, and just say 'ok'");
|
|
88
|
+
spinner.succeed("LLM API key is valid");
|
|
89
|
+
return true;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
spinner.fail(
|
|
92
|
+
chalk.red(
|
|
93
|
+
`LLM API key is invalid. Please check your API key and try again. You can set it again by running "akan set-llm" or reset by running "akan reset-llm"`,
|
|
94
|
+
),
|
|
95
|
+
);
|
|
96
|
+
throw error;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
static async clearCache(workspaceRoot: string) {
|
|
100
|
+
const cacheDir = `${workspaceRoot}/${AiSession.#cacheDir}`;
|
|
101
|
+
await Bun.$`rm -rf ${cacheDir}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
messageHistory: BaseMessage[] = [];
|
|
105
|
+
readonly sessionKey: string;
|
|
106
|
+
isCacheLoaded: boolean = false;
|
|
107
|
+
workspace: WorkspaceExecutor;
|
|
108
|
+
constructor(
|
|
109
|
+
type: string,
|
|
110
|
+
{ workspace, cacheKey, isContinued }: { workspace: WorkspaceExecutor; cacheKey?: string; isContinued?: boolean },
|
|
111
|
+
) {
|
|
112
|
+
this.workspace = workspace;
|
|
113
|
+
this.sessionKey = `${type}${cacheKey ? `-${cacheKey}` : ""}`;
|
|
114
|
+
if (isContinued) this.#cacheLoadPromise = this.#loadCache();
|
|
115
|
+
}
|
|
116
|
+
#cacheLoadPromise: Promise<void> | null = null;
|
|
117
|
+
async #loadCache() {
|
|
118
|
+
const cacheFile = `${AiSession.#cacheDir}/${this.sessionKey}.json`;
|
|
119
|
+
const isCacheExists = await this.workspace.exists(cacheFile);
|
|
120
|
+
if (isCacheExists)
|
|
121
|
+
this.messageHistory = mapStoredMessagesToChatMessages(
|
|
122
|
+
(await this.workspace.readJson(cacheFile)) as StoredMessage[],
|
|
123
|
+
);
|
|
124
|
+
else this.messageHistory = [];
|
|
125
|
+
this.isCacheLoaded = isCacheExists;
|
|
126
|
+
}
|
|
127
|
+
async #saveCache() {
|
|
128
|
+
const cacheFilePath = `${AiSession.#cacheDir}/${this.sessionKey}.json`;
|
|
129
|
+
await this.workspace.writeJson(cacheFilePath, mapChatMessagesToStoredMessages(this.messageHistory));
|
|
130
|
+
}
|
|
131
|
+
async ask(
|
|
132
|
+
question: string,
|
|
133
|
+
{
|
|
134
|
+
onReasoning = (reasoning: string) => {
|
|
135
|
+
Logger.raw(chalk.dim(reasoning));
|
|
136
|
+
},
|
|
137
|
+
onChunk = (chunk: string) => {
|
|
138
|
+
Logger.raw(chunk);
|
|
139
|
+
},
|
|
140
|
+
}: EditOptions = {},
|
|
141
|
+
): Promise<{ content: string; messageHistory: BaseMessage[] }> {
|
|
142
|
+
if (!AiSession.#chat) await AiSession.init();
|
|
143
|
+
if (this.#cacheLoadPromise) await this.#cacheLoadPromise;
|
|
144
|
+
|
|
145
|
+
if (!AiSession.#chat) throw new Error("Failed to initialize the AI session");
|
|
146
|
+
const loader = new Spinner(`${AiSession.#chat.model} is thinking...`, {
|
|
147
|
+
prefix: `🤖akan-editor`,
|
|
148
|
+
}).start();
|
|
149
|
+
try {
|
|
150
|
+
const humanMessage = new HumanMessage(question);
|
|
151
|
+
this.messageHistory.push(humanMessage);
|
|
152
|
+
const stream = await AiSession.#chat.stream(this.messageHistory);
|
|
153
|
+
let reasoningResponse = "",
|
|
154
|
+
fullResponse = "",
|
|
155
|
+
tokenIdx = 0;
|
|
156
|
+
for await (const chunk of stream) {
|
|
157
|
+
if (loader.isSpinning()) loader.succeed(`${AiSession.#chat.model} responded`);
|
|
158
|
+
|
|
159
|
+
if (!fullResponse.length) {
|
|
160
|
+
const reasoningContent = (chunk.additional_kwargs as { reasoning_content?: string }).reasoning_content ?? "";
|
|
161
|
+
if (reasoningContent.length) {
|
|
162
|
+
reasoningResponse += reasoningContent;
|
|
163
|
+
onReasoning(reasoningContent);
|
|
164
|
+
continue;
|
|
165
|
+
} else if (chunk.content.length) {
|
|
166
|
+
reasoningResponse += "\n";
|
|
167
|
+
onReasoning(reasoningResponse);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const content = chunk.content;
|
|
172
|
+
if (typeof content === "string") {
|
|
173
|
+
fullResponse += content;
|
|
174
|
+
onChunk(content); // Send individual chunks to callback
|
|
175
|
+
}
|
|
176
|
+
tokenIdx++;
|
|
177
|
+
}
|
|
178
|
+
fullResponse += "\n";
|
|
179
|
+
onChunk("\n");
|
|
180
|
+
this.messageHistory.push(new AIMessage(fullResponse));
|
|
181
|
+
return { content: fullResponse, messageHistory: this.messageHistory };
|
|
182
|
+
} catch (error) {
|
|
183
|
+
loader.fail(`${AiSession.#chat.model} failed to respond`);
|
|
184
|
+
throw new Error("Failed to stream response");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
async edit(question: string, { onChunk, onReasoning, maxTry = MAX_ASK_TRY, validate, approve }: EditOptions = {}) {
|
|
188
|
+
for (let tryCount = 0; tryCount < maxTry; tryCount++) {
|
|
189
|
+
let response = await this.ask(question, { onChunk, onReasoning });
|
|
190
|
+
if (validate?.length && tryCount === 0) {
|
|
191
|
+
const validateQuestion = `Double check if the response meets the requirements and conditions, and follow the instructions. If not, rewrite it.
|
|
192
|
+
${validate.map((v) => `- ${v}`).join("\n")}`;
|
|
193
|
+
response = await this.ask(validateQuestion, { onChunk, onReasoning });
|
|
194
|
+
}
|
|
195
|
+
const isConfirmed = approve
|
|
196
|
+
? true
|
|
197
|
+
: await select({
|
|
198
|
+
message: "Do you want to edit the response?",
|
|
199
|
+
choices: [
|
|
200
|
+
{ name: "✅ Yes, confirm and apply this result", value: true },
|
|
201
|
+
{ name: "🔄 No, I want to edit it more", value: false },
|
|
202
|
+
],
|
|
203
|
+
});
|
|
204
|
+
if (isConfirmed) {
|
|
205
|
+
await this.#saveCache();
|
|
206
|
+
return response.content;
|
|
207
|
+
}
|
|
208
|
+
question = await input({ message: "What do you want to change?" });
|
|
209
|
+
tryCount++;
|
|
210
|
+
}
|
|
211
|
+
throw new Error("Failed to edit");
|
|
212
|
+
}
|
|
213
|
+
async editTypescript(question: string, options: EditOptions = {}) {
|
|
214
|
+
const content = await this.edit(question, options);
|
|
215
|
+
return this.#getTypescriptCode(content);
|
|
216
|
+
}
|
|
217
|
+
#getTypescriptCode(content: string) {
|
|
218
|
+
//! will be deprecated
|
|
219
|
+
const code = /```(typescript|tsx)([\s\S]*?)```/.exec(content);
|
|
220
|
+
// 2번째로 해야 반환되는데 모르겟음 아무튼 일단 이렇게 함.
|
|
221
|
+
|
|
222
|
+
return code?.[2] ?? content;
|
|
223
|
+
// return code ? code[1] : content;
|
|
224
|
+
}
|
|
225
|
+
addToolMessgaes(messages: { type: string; content: string }[]) {
|
|
226
|
+
// const toolMessages = messages.map(
|
|
227
|
+
// (message) => new ToolMessage({ content: message.content, tool_call_id: message.type })
|
|
228
|
+
// );
|
|
229
|
+
const toolMessages = messages.map((message) => new HumanMessage(message.content));
|
|
230
|
+
this.messageHistory.push(...toolMessages);
|
|
231
|
+
return this;
|
|
232
|
+
}
|
|
233
|
+
async writeTypescripts(question: string, executor: Executor, options: EditOptions = {}) {
|
|
234
|
+
const content = await this.edit(question, options);
|
|
235
|
+
const writes = this.#getTypescriptCodes(content);
|
|
236
|
+
for (const write of writes) await executor.writeFile(write.filePath, write.content);
|
|
237
|
+
return await this.#tryFixTypescripts(writes, executor, options);
|
|
238
|
+
}
|
|
239
|
+
async #editTypescripts(question: string, options: EditOptions = {}) {
|
|
240
|
+
const content = await this.edit(question, options);
|
|
241
|
+
return this.#getTypescriptCodes(content);
|
|
242
|
+
}
|
|
243
|
+
async #tryFixTypescripts(writes: FileContent[], executor: Executor, options: EditOptions = {}) {
|
|
244
|
+
const MAX_EDIT_TRY = 5;
|
|
245
|
+
for (let tryCount = 0; tryCount < MAX_EDIT_TRY; tryCount++) {
|
|
246
|
+
const loader = new Spinner(`Type checking and linting...`, { prefix: `🤖akan-editor` }).start();
|
|
247
|
+
const fileChecks = await Promise.all(
|
|
248
|
+
writes.map(async ({ filePath }) => {
|
|
249
|
+
const typeCheckResult = executor.typeCheck(filePath);
|
|
250
|
+
const lintResult = await executor.lint(filePath);
|
|
251
|
+
const needFix = !!typeCheckResult.fileErrors.length || !!lintResult.errors.length;
|
|
252
|
+
return { filePath, typeCheckResult, lintResult, needFix };
|
|
253
|
+
}),
|
|
254
|
+
);
|
|
255
|
+
const needFix = fileChecks.some((fileCheck) => fileCheck.needFix);
|
|
256
|
+
if (needFix) {
|
|
257
|
+
loader.fail("Type checking and linting has some errors, try to fix them");
|
|
258
|
+
fileChecks.forEach((fileCheck) => {
|
|
259
|
+
Logger.rawLog(
|
|
260
|
+
`TypeCheck Result \n${fileCheck.typeCheckResult.message}\nLint Result \n${fileCheck.lintResult.message}`,
|
|
261
|
+
);
|
|
262
|
+
this.addToolMessgaes([
|
|
263
|
+
{ type: "typescript", content: fileCheck.typeCheckResult.message },
|
|
264
|
+
{ type: "eslint", content: fileCheck.lintResult.message },
|
|
265
|
+
]);
|
|
266
|
+
});
|
|
267
|
+
writes = await this.#editTypescripts("Fix the typescript and eslint errors", {
|
|
268
|
+
...options,
|
|
269
|
+
validate: undefined,
|
|
270
|
+
approve: true,
|
|
271
|
+
});
|
|
272
|
+
for (const write of writes) await executor.writeFile(write.filePath, write.content);
|
|
273
|
+
} else {
|
|
274
|
+
loader.succeed("Type checking and linting has no errors");
|
|
275
|
+
return writes;
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
throw new Error("Failed to create scalar");
|
|
279
|
+
}
|
|
280
|
+
#getTypescriptCodes(text: string): FileContent[] {
|
|
281
|
+
const codes = text.match(/```(typescript|tsx)([\s\S]*?)```/g);
|
|
282
|
+
if (!codes) return [];
|
|
283
|
+
const result = codes.map((code) => {
|
|
284
|
+
const content = /```(typescript|tsx)([\s\S]*?)```/.exec(code)?.[2];
|
|
285
|
+
if (!content) return null;
|
|
286
|
+
const filePath = /\/\/ File: (.*?)(?:\n|$)/.exec(content)?.[1]?.trim();
|
|
287
|
+
if (!filePath) return null;
|
|
288
|
+
const contentWithoutFilepath = content.replace(`// File: ${filePath}\n`, "").trim();
|
|
289
|
+
return { filePath, content: contentWithoutFilepath };
|
|
290
|
+
});
|
|
291
|
+
return result.filter((code) => code !== null) as FileContent[];
|
|
292
|
+
}
|
|
293
|
+
async editMarkdown(request: string, options: EditOptions = {}) {
|
|
294
|
+
const content = await this.edit(request, options);
|
|
295
|
+
return this.#getMarkdownContent(content);
|
|
296
|
+
}
|
|
297
|
+
#getMarkdownContent(text: string) {
|
|
298
|
+
const searchText = "```markdown";
|
|
299
|
+
const firstIndex = text.indexOf("```markdown");
|
|
300
|
+
const lastIndex = text.lastIndexOf("```");
|
|
301
|
+
if (firstIndex === -1) return text;
|
|
302
|
+
else return text.slice(firstIndex + searchText.length, lastIndex).trim();
|
|
303
|
+
}
|
|
304
|
+
}
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import { Logger } from "akanjs/common";
|
|
3
|
+
import type { BuilderMessage } from "akanjs/server";
|
|
4
|
+
import type { App } from "../commandDecorators";
|
|
5
|
+
import { createTunnel } from "../createTunnel";
|
|
6
|
+
import { WorkspaceExecutor } from "../executors";
|
|
7
|
+
import { IncrementalBuilderHost } from "../incrementalBuilder";
|
|
8
|
+
|
|
9
|
+
const backendMsgTypeSet = new Set<BuilderMessage["type"]>(["build-route"]);
|
|
10
|
+
const BACKEND_RESTART_DEBOUNCE_MS = 120;
|
|
11
|
+
const BACKEND_GRACEFUL_TIMEOUT_MS = 3000;
|
|
12
|
+
const BACKEND_RECOVERY_BASE_DELAY_MS = 1_000;
|
|
13
|
+
const BACKEND_RECOVERY_MAX_DELAY_MS = 30_000;
|
|
14
|
+
const BUILDER_READY_TIMEOUT_MS = 15000;
|
|
15
|
+
const BUILDER_START_MAX_ATTEMPTS = 3;
|
|
16
|
+
const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
|
|
17
|
+
const NON_SOURCE_EXT_RE =
|
|
18
|
+
/\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
|
|
19
|
+
const GRAPH_IMPORT_KINDS = new Set<Bun.ImportKind>([
|
|
20
|
+
"import-statement",
|
|
21
|
+
"require-call",
|
|
22
|
+
"require-resolve",
|
|
23
|
+
"dynamic-import",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
class BackendImportGraph {
|
|
27
|
+
readonly #app: App;
|
|
28
|
+
readonly #logger: Logger;
|
|
29
|
+
readonly #tsTranspiler = new Bun.Transpiler({ loader: "ts" });
|
|
30
|
+
readonly #tsxTranspiler = new Bun.Transpiler({ loader: "tsx" });
|
|
31
|
+
readonly #jsTranspiler = new Bun.Transpiler({ loader: "js" });
|
|
32
|
+
readonly #jsxTranspiler = new Bun.Transpiler({ loader: "jsx" });
|
|
33
|
+
#files = new Set<string>();
|
|
34
|
+
#ready = false;
|
|
35
|
+
|
|
36
|
+
constructor(app: App, logger: Logger) {
|
|
37
|
+
this.#app = app;
|
|
38
|
+
this.#logger = logger;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
get ready() {
|
|
42
|
+
return this.#ready;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
has(file: string) {
|
|
46
|
+
return this.#files.has(path.resolve(file));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
async refresh(): Promise<boolean> {
|
|
50
|
+
try {
|
|
51
|
+
const files = await this.#build();
|
|
52
|
+
this.#files = files;
|
|
53
|
+
this.#ready = true;
|
|
54
|
+
this.#logger.verbose(`[backend-graph] scanned ${files.size} files`);
|
|
55
|
+
return true;
|
|
56
|
+
} catch (err) {
|
|
57
|
+
this.#ready = this.#files.size > 0;
|
|
58
|
+
this.#logger.warn(
|
|
59
|
+
`[backend-graph] scan failed; ${this.#ready ? "using previous graph" : "using fallback rules"}: ${err instanceof Error ? err.message : String(err)}`,
|
|
60
|
+
);
|
|
61
|
+
return this.#ready;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async #build(): Promise<Set<string>> {
|
|
66
|
+
const roots = await this.#entrypoints();
|
|
67
|
+
const files = new Set<string>();
|
|
68
|
+
const queue = [...roots];
|
|
69
|
+
const workspaceRoot = path.resolve(this.#app.workspace.workspaceRoot);
|
|
70
|
+
|
|
71
|
+
while (queue.length > 0) {
|
|
72
|
+
const current = path.resolve(queue.pop() as string);
|
|
73
|
+
if (files.has(current)) continue;
|
|
74
|
+
if (!this.#isWorkspaceSource(current, workspaceRoot)) continue;
|
|
75
|
+
if (!(await Bun.file(current).exists())) continue;
|
|
76
|
+
|
|
77
|
+
files.add(current);
|
|
78
|
+
const source = await Bun.file(current).text();
|
|
79
|
+
const imports = this.#scanImports(current, source);
|
|
80
|
+
const importerDir = path.dirname(current);
|
|
81
|
+
for (const imp of imports) {
|
|
82
|
+
if (!GRAPH_IMPORT_KINDS.has(imp.kind) || !imp.path || NON_SOURCE_EXT_RE.test(imp.path)) continue;
|
|
83
|
+
const resolved = this.#resolve(imp.path, importerDir);
|
|
84
|
+
if (!resolved || files.has(resolved)) continue;
|
|
85
|
+
queue.push(resolved);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return files;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async #entrypoints(): Promise<string[]> {
|
|
92
|
+
const roots = [`${this.#app.cwdPath}/main.ts`, `${this.#app.cwdPath}/server.ts`];
|
|
93
|
+
const existing: string[] = [];
|
|
94
|
+
for (const root of roots) {
|
|
95
|
+
const abs = path.resolve(root);
|
|
96
|
+
if (await Bun.file(abs).exists()) existing.push(abs);
|
|
97
|
+
}
|
|
98
|
+
return existing;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#resolve(specifier: string, importerDir: string): string | null {
|
|
102
|
+
try {
|
|
103
|
+
const resolved = Bun.resolveSync(specifier, importerDir);
|
|
104
|
+
if (!path.isAbsolute(resolved)) return null;
|
|
105
|
+
if (!SOURCE_EXTS.has(path.extname(resolved).toLowerCase())) return null;
|
|
106
|
+
return path.resolve(resolved);
|
|
107
|
+
} catch {
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
#isWorkspaceSource(file: string, workspaceRoot: string): boolean {
|
|
113
|
+
const rel = path.relative(workspaceRoot, file);
|
|
114
|
+
if (rel.startsWith("..") || path.isAbsolute(rel)) return false;
|
|
115
|
+
if (rel.includes(`${path.sep}node_modules${path.sep}`) || rel.includes(`${path.sep}.akan${path.sep}`)) return false;
|
|
116
|
+
return SOURCE_EXTS.has(path.extname(file).toLowerCase());
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
#scanImports(file: string, source: string): Bun.Import[] {
|
|
120
|
+
const ext = path.extname(file).toLowerCase();
|
|
121
|
+
if (ext === ".tsx") return this.#tsxTranspiler.scanImports(source);
|
|
122
|
+
if (ext === ".jsx") return this.#jsxTranspiler.scanImports(source);
|
|
123
|
+
if (ext === ".js" || ext === ".mjs" || ext === ".cjs") return this.#jsTranspiler.scanImports(source);
|
|
124
|
+
return this.#tsTranspiler.scanImports(source);
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export class AkanAppHost {
|
|
129
|
+
logger = new Logger("AkanAppHost");
|
|
130
|
+
readonly withInk: boolean;
|
|
131
|
+
readonly env: Record<string, string>;
|
|
132
|
+
#backend: Bun.Subprocess<"ignore", "inherit", "inherit"> | null = null;
|
|
133
|
+
#builder: IncrementalBuilderHost | null = null;
|
|
134
|
+
#backendReady = false;
|
|
135
|
+
#plannedBackendStops = new WeakSet<Bun.Subprocess<"ignore", "inherit", "inherit">>();
|
|
136
|
+
#restartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
137
|
+
#backendRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
138
|
+
#backendRecoveryAttempts = 0;
|
|
139
|
+
#restartFiles = new Set<string>();
|
|
140
|
+
#latestPagesUpdated: Extract<BuilderMessage, { type: "pages-updated" }> | null = null;
|
|
141
|
+
#latestCssUpdated: Extract<BuilderMessage, { type: "css-updated" }> | null = null;
|
|
142
|
+
#builderMessageQueue: Promise<void> = Promise.resolve();
|
|
143
|
+
#backendGraph: BackendImportGraph;
|
|
144
|
+
constructor(
|
|
145
|
+
private readonly app: App,
|
|
146
|
+
{ env, withInk = false }: { env: Record<string, string>; withInk?: boolean },
|
|
147
|
+
) {
|
|
148
|
+
this.env = env;
|
|
149
|
+
this.withInk = withInk;
|
|
150
|
+
this.#backendGraph = new BackendImportGraph(app, this.logger);
|
|
151
|
+
}
|
|
152
|
+
async start() {
|
|
153
|
+
if (this.#backend) await this.#stopBackend();
|
|
154
|
+
if (this.#builder) this.#stopBuilder();
|
|
155
|
+
const [redisHost] = await Promise.all([
|
|
156
|
+
this.#prepareDatabase("redis"),
|
|
157
|
+
this.#backendGraph.refresh(),
|
|
158
|
+
this.#startBuilder(),
|
|
159
|
+
]);
|
|
160
|
+
Object.assign(this.env, { REDIS_HOST: redisHost });
|
|
161
|
+
this.#startBackend();
|
|
162
|
+
return this;
|
|
163
|
+
}
|
|
164
|
+
async stop() {
|
|
165
|
+
if (this.#restartTimer) {
|
|
166
|
+
clearTimeout(this.#restartTimer);
|
|
167
|
+
this.#restartTimer = null;
|
|
168
|
+
}
|
|
169
|
+
if (this.#backendRecoveryTimer) {
|
|
170
|
+
clearTimeout(this.#backendRecoveryTimer);
|
|
171
|
+
this.#backendRecoveryTimer = null;
|
|
172
|
+
}
|
|
173
|
+
await this.#stopBackend();
|
|
174
|
+
this.#stopBuilder();
|
|
175
|
+
return this;
|
|
176
|
+
}
|
|
177
|
+
kill() {
|
|
178
|
+
void this.stop();
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async #prepareDatabase(type: "redis") {
|
|
182
|
+
const environment = WorkspaceExecutor.getBaseDevEnv().env;
|
|
183
|
+
if (environment === "local") return "localhost";
|
|
184
|
+
return await createTunnel(type, { app: this.app, environment });
|
|
185
|
+
}
|
|
186
|
+
#startBackend() {
|
|
187
|
+
this.#backendReady = false;
|
|
188
|
+
const backend = Bun.spawn(["bun", `apps/${this.app.name}/main.ts`], {
|
|
189
|
+
cwd: this.app.workspace.workspaceRoot,
|
|
190
|
+
stdio: this.withInk ? ["ignore", "pipe", "pipe"] : ["inherit", "inherit", "inherit"],
|
|
191
|
+
env: this.env,
|
|
192
|
+
ipc: (msg: BuilderMessage) => {
|
|
193
|
+
if (!msg || typeof msg !== "object") return;
|
|
194
|
+
if (msg.type === "backend-ready") {
|
|
195
|
+
this.#backendReady = true;
|
|
196
|
+
this.#backendRecoveryAttempts = 0;
|
|
197
|
+
this.logger.verbose(`backend ready pid=${msg.pid}`);
|
|
198
|
+
this.#replayBuilderState();
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
if (backendMsgTypeSet.has(msg.type)) this.#sendToBuilder(msg);
|
|
202
|
+
},
|
|
203
|
+
serialization: "advanced",
|
|
204
|
+
onExit: () => {
|
|
205
|
+
this.#backendReady = false;
|
|
206
|
+
if (this.#backend === backend) this.#backend = null;
|
|
207
|
+
if (this.#plannedBackendStops.has(backend)) {
|
|
208
|
+
this.#plannedBackendStops.delete(backend);
|
|
209
|
+
return;
|
|
210
|
+
}
|
|
211
|
+
this.#scheduleBackendRecovery("backend-exit");
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
this.#backend = backend;
|
|
215
|
+
this.logger.verbose(`backend spawned pid=${backend.pid}`);
|
|
216
|
+
}
|
|
217
|
+
#sendToBackend(message: BuilderMessage) {
|
|
218
|
+
if (!this.#backend || !this.#backendReady) {
|
|
219
|
+
if (message.type === "css-updated" || message.type === "pages-updated") {
|
|
220
|
+
this.logger.verbose(`backend is not ready; will replay ${message.type}`);
|
|
221
|
+
return;
|
|
222
|
+
}
|
|
223
|
+
if (message.type !== "builder-ready") this.logger.warn(`backend is not ready; dropping ${message.type}`);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
this.#backend.send(message);
|
|
228
|
+
} catch (err) {
|
|
229
|
+
this.logger.warn(
|
|
230
|
+
`failed to send ${message.type} to backend: ${err instanceof Error ? err.message : String(err)}`,
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async #stopBackend() {
|
|
235
|
+
if (!this.#backend) return;
|
|
236
|
+
const backend = this.#backend;
|
|
237
|
+
this.#plannedBackendStops.add(backend);
|
|
238
|
+
this.#backendReady = false;
|
|
239
|
+
this.logger.verbose(`stopping backend pid=${backend.pid}`);
|
|
240
|
+
try {
|
|
241
|
+
backend.kill("SIGTERM");
|
|
242
|
+
const timeout = new Promise<"timeout">((resolve) =>
|
|
243
|
+
setTimeout(() => resolve("timeout"), BACKEND_GRACEFUL_TIMEOUT_MS),
|
|
244
|
+
);
|
|
245
|
+
const result = await Promise.race([backend.exited, timeout]);
|
|
246
|
+
if (result === "timeout") {
|
|
247
|
+
this.logger.warn(`backend pid=${backend.pid} did not exit in ${BACKEND_GRACEFUL_TIMEOUT_MS}ms; force killing`);
|
|
248
|
+
backend.kill("SIGKILL");
|
|
249
|
+
await backend.exited.catch(() => undefined);
|
|
250
|
+
}
|
|
251
|
+
} finally {
|
|
252
|
+
if (this.#backend === backend) this.#backend = null;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
#scheduleBackendRestart(files: string[]) {
|
|
256
|
+
for (const file of files) this.#restartFiles.add(file);
|
|
257
|
+
if (this.#backendRecoveryTimer) {
|
|
258
|
+
clearTimeout(this.#backendRecoveryTimer);
|
|
259
|
+
this.#backendRecoveryTimer = null;
|
|
260
|
+
}
|
|
261
|
+
if (this.#restartTimer) clearTimeout(this.#restartTimer);
|
|
262
|
+
this.#restartTimer = setTimeout(() => {
|
|
263
|
+
this.#restartTimer = null;
|
|
264
|
+
const changed = [...this.#restartFiles];
|
|
265
|
+
this.#restartFiles.clear();
|
|
266
|
+
void this.#restartBackend(changed);
|
|
267
|
+
}, BACKEND_RESTART_DEBOUNCE_MS);
|
|
268
|
+
}
|
|
269
|
+
async #restartBackend(files: string[]) {
|
|
270
|
+
this.logger.verbose(`[backend-reload] restarting backend for ${files.length} file(s)`);
|
|
271
|
+
this.#backendRecoveryAttempts = 0;
|
|
272
|
+
await Promise.all([this.#stopBackend(), this.#backendGraph.refresh()]);
|
|
273
|
+
this.#startBackend();
|
|
274
|
+
}
|
|
275
|
+
#scheduleBackendRecovery(reason: string) {
|
|
276
|
+
if (this.#backendRecoveryTimer || this.#backend) return;
|
|
277
|
+
const attempt = this.#backendRecoveryAttempts;
|
|
278
|
+
const delay = Math.min(BACKEND_RECOVERY_BASE_DELAY_MS * 2 ** attempt, BACKEND_RECOVERY_MAX_DELAY_MS);
|
|
279
|
+
this.#backendRecoveryAttempts = attempt + 1;
|
|
280
|
+
this.logger.warn(
|
|
281
|
+
`[backend-recovery] backend exited unexpectedly (${reason}); restarting in ${delay}ms (attempt ${this.#backendRecoveryAttempts})`,
|
|
282
|
+
);
|
|
283
|
+
this.#backendRecoveryTimer = setTimeout(() => {
|
|
284
|
+
this.#backendRecoveryTimer = null;
|
|
285
|
+
if (this.#backend) return;
|
|
286
|
+
void this.#backendGraph.refresh().finally(() => {
|
|
287
|
+
if (!this.#backend) this.#startBackend();
|
|
288
|
+
});
|
|
289
|
+
}, delay);
|
|
290
|
+
}
|
|
291
|
+
#enqueueBuilderMessage(message: BuilderMessage) {
|
|
292
|
+
this.#builderMessageQueue = this.#builderMessageQueue
|
|
293
|
+
.then(() => this.#handleBuilderMessage(message))
|
|
294
|
+
.catch((err) => {
|
|
295
|
+
this.logger.warn(`failed to handle builder message: ${err instanceof Error ? err.message : String(err)}`);
|
|
296
|
+
});
|
|
297
|
+
}
|
|
298
|
+
async #handleBuilderMessage(message: BuilderMessage) {
|
|
299
|
+
if (message.type === "pages-updated") this.#latestPagesUpdated = message;
|
|
300
|
+
if (message.type === "css-updated") this.#latestCssUpdated = message;
|
|
301
|
+
if (message.type === "invalidate") {
|
|
302
|
+
await this.#handleInvalidate(message);
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
this.#sendToBackend(message);
|
|
306
|
+
}
|
|
307
|
+
async #handleInvalidate(message: Extract<BuilderMessage, { type: "invalidate" }>) {
|
|
308
|
+
if (await this.#shouldRestartBackend(message)) {
|
|
309
|
+
this.#scheduleBackendRestart(message.files);
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
this.#sendToBackend(message);
|
|
313
|
+
}
|
|
314
|
+
#replayBuilderState() {
|
|
315
|
+
if (!this.#backendReady) return;
|
|
316
|
+
if (this.#latestCssUpdated) this.#sendToBackend(this.#latestCssUpdated);
|
|
317
|
+
if (this.#latestPagesUpdated) this.#sendToBackend(this.#latestPagesUpdated);
|
|
318
|
+
}
|
|
319
|
+
async #shouldRestartBackend(message: Extract<BuilderMessage, { type: "invalidate" }>): Promise<boolean> {
|
|
320
|
+
if (message.kinds.length === 1 && message.kinds[0] === "css") return false;
|
|
321
|
+
if (!this.#backendGraph.ready && message.kinds.includes("code")) await this.#backendGraph.refresh();
|
|
322
|
+
return message.files.some((file) => this.#isBackendFile(file));
|
|
323
|
+
}
|
|
324
|
+
#isBackendFile(file: string): boolean {
|
|
325
|
+
return this.#backendGraph.has(file);
|
|
326
|
+
}
|
|
327
|
+
async #startBuilder() {
|
|
328
|
+
const startTime = Date.now();
|
|
329
|
+
this.app.verbose(`[cli] waiting for builder to complete initial base build…`);
|
|
330
|
+
let lastError: unknown;
|
|
331
|
+
for (let attempt = 1; attempt <= BUILDER_START_MAX_ATTEMPTS; attempt++) {
|
|
332
|
+
this.#builder = await IncrementalBuilderHost.create(this.app, this.env, (msg) => {
|
|
333
|
+
this.#enqueueBuilderMessage(msg);
|
|
334
|
+
});
|
|
335
|
+
try {
|
|
336
|
+
await this.#waitForBuilderReady(attempt);
|
|
337
|
+
this.app.verbose(`[cli] base build ready in ${Date.now() - startTime}ms — starting backend`);
|
|
338
|
+
return this.#builder;
|
|
339
|
+
} catch (err) {
|
|
340
|
+
lastError = err;
|
|
341
|
+
this.#stopBuilder();
|
|
342
|
+
if (attempt >= BUILDER_START_MAX_ATTEMPTS) break;
|
|
343
|
+
this.app.verbose(`[cli] builder failed before ready; retrying (${attempt + 1}/${BUILDER_START_MAX_ATTEMPTS})`);
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
throw lastError instanceof Error ? lastError : new Error(String(lastError));
|
|
347
|
+
}
|
|
348
|
+
#waitForBuilderReady(attempt: number) {
|
|
349
|
+
return new Promise<void>((resolve, reject) => {
|
|
350
|
+
if (!this.#builder) throw new Error("Builder Not Found");
|
|
351
|
+
let settled = false;
|
|
352
|
+
const settle = (fn: () => void) => {
|
|
353
|
+
if (settled) return;
|
|
354
|
+
settled = true;
|
|
355
|
+
clearTimeout(timeout);
|
|
356
|
+
fn();
|
|
357
|
+
};
|
|
358
|
+
const timeout = setTimeout(() => {
|
|
359
|
+
settle(() => reject(new Error("[cli] builder timed out before emitting builder-ready")));
|
|
360
|
+
}, BUILDER_READY_TIMEOUT_MS);
|
|
361
|
+
this.#builder.start({
|
|
362
|
+
onExit: () => {
|
|
363
|
+
settle(() => reject(new Error(`[cli] builder exited before emitting builder-ready (attempt ${attempt})`)));
|
|
364
|
+
},
|
|
365
|
+
onReady: () => {
|
|
366
|
+
settle(resolve);
|
|
367
|
+
},
|
|
368
|
+
onRestartReady: () => {
|
|
369
|
+
this.logger.verbose("[builder-recovery] builder ready after restart; replaying latest state");
|
|
370
|
+
this.#replayBuilderState();
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
#sendToBuilder(message: BuilderMessage) {
|
|
376
|
+
if (this.#builder?.send(message)) return;
|
|
377
|
+
if (message.type === "build-route") {
|
|
378
|
+
this.#sendToBackend({
|
|
379
|
+
type: "build-route-res",
|
|
380
|
+
id: message.id,
|
|
381
|
+
ok: false,
|
|
382
|
+
error: `builder is ${this.#builder?.status ?? "stopped"}; reload after the builder is ready`,
|
|
383
|
+
});
|
|
384
|
+
return;
|
|
385
|
+
}
|
|
386
|
+
this.logger.warn("akanAppHost builder is not running");
|
|
387
|
+
}
|
|
388
|
+
#stopBuilder() {
|
|
389
|
+
if (!this.#builder) return;
|
|
390
|
+
this.#builder.stop();
|
|
391
|
+
this.#builder = null;
|
|
392
|
+
}
|
|
393
|
+
}
|
package/akanApp/index.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from "./akanApp.host";
|