@akanjs/devkit 1.0.20 → 2.1.0-rc.1
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/README.ko.md +65 -0
- package/README.md +62 -6
- 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 +351 -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/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
|
@@ -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";
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import type { PackageJson } from "../types";
|
|
6
|
+
import { AkanAppConfig, AkanLibConfig } from "./akanConfig";
|
|
7
|
+
import type { DeepPartial, LibConfigResult } from "./types";
|
|
8
|
+
|
|
9
|
+
const akanPackageJson = JSON.parse(
|
|
10
|
+
fs.readFileSync(path.join(path.dirname(fileURLToPath(import.meta.url)), "../../../akanjs/package.json"), "utf8"),
|
|
11
|
+
) as PackageJson;
|
|
12
|
+
|
|
13
|
+
const packageJson: PackageJson = {
|
|
14
|
+
name: "repo",
|
|
15
|
+
version: "1.0.0",
|
|
16
|
+
description: "repo",
|
|
17
|
+
dependencies: {
|
|
18
|
+
react: "19.0.0",
|
|
19
|
+
"react-dom": "19.0.0",
|
|
20
|
+
"react-server-dom-webpack": "19.0.0",
|
|
21
|
+
sharp: "1.0.0",
|
|
22
|
+
"@external/runtime": "2.0.0",
|
|
23
|
+
},
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const app = { name: "portal" } as never;
|
|
27
|
+
const baseDevEnv = {
|
|
28
|
+
repoName: "akansoft",
|
|
29
|
+
serveDomain: "akamir.com",
|
|
30
|
+
env: "debug" as const,
|
|
31
|
+
portOffset: 0,
|
|
32
|
+
workspaceRoot: "/workspace",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
describe("AkanAppConfig", () => {
|
|
36
|
+
test("applies defaults for route domains, i18n, image, mobile, and imports", () => {
|
|
37
|
+
const config = new AkanAppConfig(app, ["shared"], packageJson, {}, baseDevEnv);
|
|
38
|
+
|
|
39
|
+
expect([...config.domains].sort()).toEqual([
|
|
40
|
+
"portal-debug.akamir.com",
|
|
41
|
+
"portal-develop.akamir.com",
|
|
42
|
+
"portal-main.akamir.com",
|
|
43
|
+
]);
|
|
44
|
+
expect(config.basePaths.size).toBe(0);
|
|
45
|
+
expect(config.i18n.defaultLocale).toBe("en");
|
|
46
|
+
expect(config.i18n.locales).toContain("en");
|
|
47
|
+
expect(config.images.formats).toEqual(["image/webp"]);
|
|
48
|
+
expect(config.mobile).toMatchObject({
|
|
49
|
+
appName: "portal",
|
|
50
|
+
appId: "com.portal.app",
|
|
51
|
+
version: "0.0.1",
|
|
52
|
+
buildNum: 1,
|
|
53
|
+
targets: {
|
|
54
|
+
default: {
|
|
55
|
+
name: "default",
|
|
56
|
+
appName: "portal",
|
|
57
|
+
appId: "com.portal.app",
|
|
58
|
+
version: "0.0.1",
|
|
59
|
+
buildNum: 1,
|
|
60
|
+
},
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
expect(config.barrelImports).toEqual(
|
|
64
|
+
expect.arrayContaining(["@apps/portal/ui", "@libs/shared/server", "akanjs/common"]),
|
|
65
|
+
);
|
|
66
|
+
expect(config.docker.content).toContain("ENV AKAN_PUBLIC_APP_NAME=portal");
|
|
67
|
+
expect(process.env.AKAN_PUBLIC_DEFAULT_LOCALE).toBe("en");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("normalizes explicit routes, branch domains, base paths, and docker options", () => {
|
|
71
|
+
const config = new AkanAppConfig(
|
|
72
|
+
app,
|
|
73
|
+
[],
|
|
74
|
+
packageJson,
|
|
75
|
+
{
|
|
76
|
+
routes: [
|
|
77
|
+
{ domains: { debug: ["Root.Local:8282"], qa: ["QA.Root.Local"] } },
|
|
78
|
+
{ basePath: "/admin/", domains: { debug: ["Admin.Local:8282"], main: ["Admin.Main.Local"] } },
|
|
79
|
+
],
|
|
80
|
+
i18n: { locales: ["ko", "en"], defaultLocale: "ko" },
|
|
81
|
+
mobile: { appName: "Portal App", appId: "com.portal.mobile", version: "1.2.3", buildNum: 7 },
|
|
82
|
+
images: { qualities: [80, 90], dangerouslyAllowSVG: true },
|
|
83
|
+
docker: {
|
|
84
|
+
image: { amd64: "oven/bun:amd64", arm64: "oven/bun:arm64" },
|
|
85
|
+
preRuns: ["echo before", { arm64: "echo arm" }],
|
|
86
|
+
postRuns: ["echo after"],
|
|
87
|
+
command: ["bun", "server.js"],
|
|
88
|
+
},
|
|
89
|
+
optimizeImports: ["custom-icons"],
|
|
90
|
+
publicEnv: ["AKAN_PUBLIC_FEATURE"],
|
|
91
|
+
},
|
|
92
|
+
baseDevEnv,
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
expect([...config.domains].sort()).toEqual(["qa.root.local", "root.local"]);
|
|
96
|
+
expect([...config.basePaths]).toEqual(["admin"]);
|
|
97
|
+
expect([...(config.subRoutes.get("admin") ?? [])].sort()).toEqual([
|
|
98
|
+
"admin-debug.akamir.com",
|
|
99
|
+
"admin-develop.akamir.com",
|
|
100
|
+
"admin-main.akamir.com",
|
|
101
|
+
"admin-qa.akamir.com",
|
|
102
|
+
"admin.local",
|
|
103
|
+
"admin.main.local",
|
|
104
|
+
]);
|
|
105
|
+
expect([...config.branches].sort()).toEqual(["debug", "develop", "main", "qa"]);
|
|
106
|
+
expect(config.i18n.defaultLocale).toBe("ko");
|
|
107
|
+
expect(config.images.qualities).toEqual([80, 90]);
|
|
108
|
+
expect(config.images.dangerouslyAllowSVG).toBe(true);
|
|
109
|
+
expect(config.mobile.buildNum).toBe(7);
|
|
110
|
+
expect(config.mobile.targets.default).toMatchObject({
|
|
111
|
+
name: "default",
|
|
112
|
+
appName: "Portal App",
|
|
113
|
+
appId: "com.portal.mobile",
|
|
114
|
+
version: "1.2.3",
|
|
115
|
+
buildNum: 7,
|
|
116
|
+
});
|
|
117
|
+
expect(config.publicEnv).toEqual(["AKAN_PUBLIC_FEATURE"]);
|
|
118
|
+
expect(config.optimizeImports).toContain("custom-icons");
|
|
119
|
+
expect(config.docker.content).toContain('CMD ["bun","server.js"]');
|
|
120
|
+
expect(config.docker.content).toContain("FROM oven/bun:amd64 AS amd64");
|
|
121
|
+
expect(config.docker.content).toContain('RUN if [ "$TARGETARCH" = "arm64"');
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("creates production package json and reports missing external versions", () => {
|
|
125
|
+
const config = new AkanAppConfig(app, [], packageJson, { externalLibs: ["@external/runtime"] }, baseDevEnv);
|
|
126
|
+
|
|
127
|
+
expect(config.getProductionPackageJson({ scripts: { start: "bun main.js" } })).toMatchObject({
|
|
128
|
+
name: "portal",
|
|
129
|
+
main: "./main.js",
|
|
130
|
+
scripts: { start: "bun main.js" },
|
|
131
|
+
dependencies: {
|
|
132
|
+
react: "19.0.0",
|
|
133
|
+
"react-dom": "19.0.0",
|
|
134
|
+
"react-server-dom-webpack": "19.0.0",
|
|
135
|
+
sharp: "1.0.0",
|
|
136
|
+
"@external/runtime": "2.0.0",
|
|
137
|
+
},
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
const brokenConfig = new AkanAppConfig(
|
|
141
|
+
app,
|
|
142
|
+
[],
|
|
143
|
+
{ ...packageJson, dependencies: { react: "19.0.0" } },
|
|
144
|
+
{ externalLibs: ["missing-lib"] },
|
|
145
|
+
baseDevEnv,
|
|
146
|
+
);
|
|
147
|
+
expect(() => brokenConfig.getProductionPackageJson()).toThrow("Dependency missing-lib not found");
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("falls back to akanjs package versions for built-in runtime dependencies", () => {
|
|
151
|
+
const runtimeDependencies = { ...akanPackageJson.dependencies, ...akanPackageJson.peerDependencies };
|
|
152
|
+
const config = new AkanAppConfig(
|
|
153
|
+
app,
|
|
154
|
+
[],
|
|
155
|
+
{
|
|
156
|
+
name: "repo",
|
|
157
|
+
version: "1.0.0",
|
|
158
|
+
description: "repo",
|
|
159
|
+
dependencies: {
|
|
160
|
+
akanjs: "2.0.5-canary.0",
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
{},
|
|
164
|
+
baseDevEnv,
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
expect(config.getProductionPackageJson().dependencies).toEqual({
|
|
168
|
+
react: runtimeDependencies.react,
|
|
169
|
+
"react-dom": runtimeDependencies["react-dom"],
|
|
170
|
+
"react-server-dom-webpack": runtimeDependencies["react-server-dom-webpack"],
|
|
171
|
+
sharp: runtimeDependencies.sharp,
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("normalizes multiple mobile targets and validates base paths", () => {
|
|
176
|
+
const config = new AkanAppConfig(
|
|
177
|
+
app,
|
|
178
|
+
[],
|
|
179
|
+
packageJson,
|
|
180
|
+
{
|
|
181
|
+
routes: [{ basePath: "admin", domains: {} }],
|
|
182
|
+
mobile: {
|
|
183
|
+
appName: "Portal",
|
|
184
|
+
appId: "com.portal.app",
|
|
185
|
+
version: "1.0.0",
|
|
186
|
+
buildNum: 3,
|
|
187
|
+
targets: {
|
|
188
|
+
admin: {
|
|
189
|
+
basePath: "admin",
|
|
190
|
+
appName: "Portal Admin",
|
|
191
|
+
appId: "com.portal.admin",
|
|
192
|
+
buildNum: 8,
|
|
193
|
+
permissions: ["camera"],
|
|
194
|
+
links: { schemes: ["portal-admin"] },
|
|
195
|
+
},
|
|
196
|
+
},
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
baseDevEnv,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
expect(config.mobile.targets.admin).toMatchObject({
|
|
203
|
+
name: "admin",
|
|
204
|
+
basePath: "admin",
|
|
205
|
+
appName: "Portal Admin",
|
|
206
|
+
appId: "com.portal.admin",
|
|
207
|
+
version: "1.0.0",
|
|
208
|
+
buildNum: 8,
|
|
209
|
+
permissions: ["camera"],
|
|
210
|
+
links: { schemes: ["portal-admin"] },
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
expect(
|
|
214
|
+
() =>
|
|
215
|
+
new AkanAppConfig(
|
|
216
|
+
app,
|
|
217
|
+
[],
|
|
218
|
+
packageJson,
|
|
219
|
+
{
|
|
220
|
+
mobile: { targets: { bad: { basePath: "missing" } } },
|
|
221
|
+
},
|
|
222
|
+
baseDevEnv,
|
|
223
|
+
),
|
|
224
|
+
).toThrow("unknown basePath");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe("AkanLibConfig", () => {
|
|
229
|
+
test("uses empty external libs by default and preserves explicit libs", () => {
|
|
230
|
+
const lib = { name: "shared" } as never;
|
|
231
|
+
expect(new AkanLibConfig(lib, {}).externalLibs).toEqual([]);
|
|
232
|
+
|
|
233
|
+
const config: DeepPartial<LibConfigResult> = { externalLibs: ["firebase-admin"] };
|
|
234
|
+
expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
|
|
235
|
+
});
|
|
236
|
+
});
|