akanjs 2.0.6 → 2.0.7
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 +1 -1
- package/README.md +1 -1
- package/cli/application/application.command.ts +4 -1
- package/cli/application/application.runner.ts +5 -7
- package/cli/build.ts +1 -0
- package/cli/index.js +114 -74
- package/constant/serialize.ts +1 -1
- package/devkit/capacitor.base.config.ts +18 -4
- package/devkit/capacitorApp.ts +118 -64
- package/devkit/mobile/mobileTarget.ts +2 -1
- package/devkit/scanInfo.ts +1 -0
- package/package.json +1 -1
- package/server/akanApp.ts +53 -12
- package/server/processMetricsCollector.ts +79 -1
- package/server/resolver/database.resolver.ts +82 -31
- package/server/resolver/signal.resolver.ts +67 -28
- package/service/ipcTypes.ts +5 -0
- package/service/predefinedAdaptor/database.adaptor.ts +95 -27
- package/service/predefinedAdaptor/solidSqlite.ts +7 -7
- package/service/predefinedAdaptor/storage.adaptor.ts +35 -9
- package/signal/index.ts +1 -0
- package/signal/middleware.ts +5 -1
- package/signal/signalContext.ts +85 -31
- package/signal/trace.ts +279 -0
- package/types/devkit/capacitorApp.d.ts +14 -5
- package/types/server/processMetricsCollector.d.ts +2 -0
- package/types/service/ipcTypes.d.ts +5 -0
- package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -32
- package/types/service/predefinedAdaptor/solidSqlite.d.ts +3 -3
- package/types/service/predefinedAdaptor/storage.adaptor.d.ts +8 -2
- package/types/signal/index.d.ts +1 -0
- package/types/signal/signalContext.d.ts +4 -1
- package/types/signal/trace.d.ts +97 -0
- package/types/ui/Signal/style.d.ts +15 -0
- package/ui/Signal/Arg.tsx +22 -15
- package/ui/Signal/Doc.tsx +28 -21
- package/ui/Signal/Listener.tsx +15 -39
- package/ui/Signal/Message.tsx +32 -50
- package/ui/Signal/Object.tsx +16 -13
- package/ui/Signal/PubSub.tsx +29 -47
- package/ui/Signal/Response.tsx +7 -17
- package/ui/Signal/RestApi.tsx +41 -57
- package/ui/Signal/WebSocket.tsx +1 -1
- package/ui/Signal/style.ts +36 -0
- package/webkit/useCsrValues.ts +147 -37
package/devkit/capacitorApp.ts
CHANGED
|
@@ -15,37 +15,53 @@ interface RunConfig {
|
|
|
15
15
|
regenerate?: boolean;
|
|
16
16
|
}
|
|
17
17
|
|
|
18
|
+
interface PrepareConfig extends RunConfig {}
|
|
19
|
+
|
|
18
20
|
export class CapacitorApp {
|
|
19
21
|
project: MobileProject & { ios: IosProject; android: AndroidProject };
|
|
20
22
|
iosTargetName = "App";
|
|
21
23
|
readonly targetRoot: string;
|
|
24
|
+
readonly targetRootPath: string;
|
|
25
|
+
readonly targetWebRoot: string;
|
|
26
|
+
readonly targetAssetRoot: string;
|
|
27
|
+
readonly iosRootPath = "ios";
|
|
28
|
+
readonly iosProjectPath = "ios/App";
|
|
29
|
+
readonly androidRootPath = "android";
|
|
22
30
|
constructor(
|
|
23
31
|
private readonly app: AppExecutor,
|
|
24
32
|
readonly target: AkanMobileTargetConfig,
|
|
25
33
|
) {
|
|
26
|
-
this.
|
|
27
|
-
this.
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
this.targetRootPath = path.posix.join("mobile", this.target.name);
|
|
35
|
+
this.targetRoot = path.join(this.app.cwdPath, this.targetRootPath);
|
|
36
|
+
this.targetWebRoot = path.join(this.targetRoot, "www");
|
|
37
|
+
this.targetAssetRoot = path.join(this.targetRoot, "assets");
|
|
38
|
+
this.project = new MobileProject(this.app.cwdPath, {
|
|
39
|
+
android: { path: this.androidRootPath },
|
|
40
|
+
ios: { path: this.iosProjectPath },
|
|
30
41
|
}) as MobileProject & { ios: IosProject; android: AndroidProject };
|
|
31
42
|
}
|
|
32
|
-
async init({
|
|
43
|
+
async init({
|
|
44
|
+
platform,
|
|
45
|
+
operation = "release",
|
|
46
|
+
env = "debug",
|
|
47
|
+
regenerate = false,
|
|
48
|
+
}: { platform?: "ios" | "android" } & Partial<PrepareConfig> = {}) {
|
|
33
49
|
await mkdir(this.targetRoot, { recursive: true });
|
|
34
50
|
await this.#writeCapacitorConfig();
|
|
35
51
|
if (regenerate) {
|
|
36
52
|
if (!platform || platform === "ios")
|
|
37
|
-
await rm(path.join(this.
|
|
53
|
+
await rm(path.join(this.app.cwdPath, this.iosRootPath), { recursive: true, force: true });
|
|
38
54
|
if (!platform || platform === "android")
|
|
39
|
-
await rm(path.join(this.
|
|
55
|
+
await rm(path.join(this.app.cwdPath, this.androidRootPath), { recursive: true, force: true });
|
|
40
56
|
}
|
|
41
57
|
const project = this.project as MobileProject;
|
|
42
58
|
await this.project.load();
|
|
43
59
|
if ((!platform || platform === "android") && !project.android) {
|
|
44
|
-
await this.#
|
|
60
|
+
await this.#spawnMobile("npx", ["cap", "add", "android"], { operation, env });
|
|
45
61
|
await this.project.load();
|
|
46
62
|
}
|
|
47
63
|
if ((!platform || platform === "ios") && !project.ios) {
|
|
48
|
-
await this.#
|
|
64
|
+
await this.#spawnMobile("npx", ["cap", "add", "ios"], { operation, env });
|
|
49
65
|
await this.project.load();
|
|
50
66
|
}
|
|
51
67
|
return this;
|
|
@@ -53,58 +69,54 @@ export class CapacitorApp {
|
|
|
53
69
|
async save() {
|
|
54
70
|
await this.project.commit();
|
|
55
71
|
}
|
|
56
|
-
async #prepareIos({ regenerate = false }:
|
|
57
|
-
await this.init({ platform: "ios", regenerate });
|
|
72
|
+
async #prepareIos({ operation, env, regenerate = false }: PrepareConfig) {
|
|
73
|
+
await this.init({ platform: "ios", operation, env, regenerate });
|
|
58
74
|
await this.#prepareTargetAssets();
|
|
59
75
|
await this.#prepareExternalFiles("ios");
|
|
60
76
|
await this.#applyIosMetadata();
|
|
61
77
|
await this.#applyPermissions();
|
|
62
78
|
await this.#applyLinks();
|
|
63
79
|
await this.project.commit();
|
|
64
|
-
await this.#generateAssets();
|
|
80
|
+
await this.#generateAssets({ operation, env });
|
|
65
81
|
this.app.verbose(`syncing iOS`);
|
|
66
|
-
await this.#
|
|
82
|
+
await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env });
|
|
67
83
|
this.app.verbose(`sync completed.`);
|
|
68
84
|
}
|
|
69
|
-
async buildIos(
|
|
85
|
+
async buildIos({ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {}) {
|
|
70
86
|
await this.prepareWww();
|
|
71
|
-
await this.#prepareIos(
|
|
72
|
-
await this.#
|
|
87
|
+
await this.#prepareIos({ operation: "release", env, regenerate });
|
|
88
|
+
await this.#spawnMobile("npx", ["cap", "build", "ios"], { operation: "release", env }, { stdio: "inherit" });
|
|
73
89
|
this.app.verbose(`build completed iOS.`);
|
|
74
90
|
return;
|
|
75
91
|
}
|
|
76
92
|
async syncIos() {
|
|
77
|
-
await this.#
|
|
93
|
+
await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation: "local", env: "local" });
|
|
78
94
|
}
|
|
79
95
|
async openIos() {
|
|
80
|
-
await this.#
|
|
96
|
+
await this.#spawnMobile("npx", ["cap", "open", "ios"], { operation: "local", env: "local" });
|
|
81
97
|
}
|
|
82
98
|
async runIos({ operation, env, regenerate = false }: RunConfig) {
|
|
83
99
|
if (operation === "release") await this.prepareWww();
|
|
84
|
-
await this.#prepareIos({ regenerate });
|
|
100
|
+
await this.#prepareIos({ operation, env, regenerate });
|
|
85
101
|
const args = ["cap", "run", "ios"];
|
|
86
|
-
|
|
87
|
-
await this.#spawn("npx", args, {
|
|
88
|
-
env: this.#commandEnv(operation, env),
|
|
89
|
-
stdio: "inherit",
|
|
90
|
-
});
|
|
102
|
+
await this.#spawnMobile("npx", args, { operation, env }, { stdio: "inherit" });
|
|
91
103
|
}
|
|
92
104
|
|
|
93
|
-
async #prepareAndroid({ regenerate = false }:
|
|
94
|
-
await this.init({ platform: "android", regenerate });
|
|
105
|
+
async #prepareAndroid({ operation, env, regenerate = false }: PrepareConfig) {
|
|
106
|
+
await this.init({ platform: "android", operation, env, regenerate });
|
|
95
107
|
await this.#prepareTargetAssets();
|
|
96
108
|
await this.#prepareExternalFiles("android");
|
|
97
109
|
await this.#applyAndroidMetadata();
|
|
98
110
|
await this.#applyPermissions();
|
|
99
111
|
await this.#applyLinks();
|
|
100
112
|
await this.project.commit();
|
|
101
|
-
await this.#generateAssets();
|
|
102
|
-
await this.#
|
|
113
|
+
await this.#generateAssets({ operation, env });
|
|
114
|
+
await this.#spawnMobile("npx", ["cap", "sync", "android"], { operation, env });
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
async #updateAndroidBuildTypes() {
|
|
106
118
|
|
|
107
|
-
const appGradle = await FileEditor.create(
|
|
119
|
+
const appGradle = await FileEditor.create(path.join(this.app.cwdPath, this.androidRootPath, "app/build.gradle"));
|
|
108
120
|
const buildTypesBlock = `
|
|
109
121
|
debug {
|
|
110
122
|
applicationIdSuffix ".debug"
|
|
@@ -139,9 +151,12 @@ export class CapacitorApp {
|
|
|
139
151
|
}
|
|
140
152
|
await appGradle.save();
|
|
141
153
|
}
|
|
142
|
-
async buildAndroid(
|
|
154
|
+
async buildAndroid(
|
|
155
|
+
assembleType: "apk" | "aab",
|
|
156
|
+
{ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {},
|
|
157
|
+
) {
|
|
143
158
|
await this.prepareWww();
|
|
144
|
-
await this.#prepareAndroid(
|
|
159
|
+
await this.#prepareAndroid({ operation: "release", env, regenerate });
|
|
145
160
|
await this.#updateAndroidBuildTypes();
|
|
146
161
|
|
|
147
162
|
const isWindows = process.platform === "win32";
|
|
@@ -149,45 +164,44 @@ export class CapacitorApp {
|
|
|
149
164
|
|
|
150
165
|
await this.app.spawn(gradleCommand, [assembleType === "apk" ? "assembleRelease" : "bundleRelease"], {
|
|
151
166
|
stdio: "inherit",
|
|
152
|
-
cwd:
|
|
167
|
+
cwd: path.join(this.app.cwdPath, this.androidRootPath),
|
|
168
|
+
env: this.#commandEnv("release", env),
|
|
153
169
|
});
|
|
154
170
|
}
|
|
155
171
|
async openAndroid() {
|
|
156
|
-
await this.#
|
|
172
|
+
await this.#spawnMobile("npx", ["cap", "open", "android"], { operation: "local", env: "local" });
|
|
157
173
|
}
|
|
158
174
|
async syncAndroid(options: { regenerate?: boolean } = {}) {
|
|
159
175
|
await this.prepareWww();
|
|
160
|
-
await this.#prepareAndroid(options);
|
|
176
|
+
await this.#prepareAndroid({ operation: "release", env: "debug", ...options });
|
|
161
177
|
this.app.log(`Sync Android Completed.`);
|
|
162
178
|
}
|
|
163
179
|
async runAndroid({ operation, env, regenerate = false }: RunConfig) {
|
|
164
180
|
if (operation === "release") await this.prepareWww();
|
|
165
|
-
await this.#prepareAndroid({ regenerate });
|
|
181
|
+
await this.#prepareAndroid({ operation, env, regenerate });
|
|
166
182
|
this.app.logger.info(`Running Android in ${operation} mode on ${env} env`);
|
|
167
183
|
const args = ["cap", "run", "android"];
|
|
168
|
-
|
|
169
|
-
await this.#spawn("npx", args, {
|
|
170
|
-
env: this.#commandEnv(operation, env),
|
|
171
|
-
stdio: "inherit",
|
|
172
|
-
});
|
|
184
|
+
await this.#spawnMobile("npx", args, { operation, env }, { stdio: "inherit" });
|
|
173
185
|
}
|
|
174
186
|
|
|
175
187
|
async releaseIos() {
|
|
176
188
|
await this.prepareWww();
|
|
177
|
-
await this.#prepareIos();
|
|
189
|
+
await this.#prepareIos({ operation: "release", env: "main" });
|
|
178
190
|
}
|
|
179
191
|
async releaseAndroid() {
|
|
180
192
|
await this.prepareWww();
|
|
181
|
-
await this.#prepareAndroid();
|
|
193
|
+
await this.#prepareAndroid({ operation: "release", env: "main" });
|
|
182
194
|
}
|
|
183
195
|
async prepareWww() {
|
|
184
196
|
const htmlSource = path.join(this.app.dist.cwdPath, "csr", targetHtmlFilename(this.target));
|
|
185
197
|
if (!(await Bun.file(htmlSource).exists()))
|
|
186
198
|
throw new Error(`CSR html for mobile target '${this.target.name}' not found: ${htmlSource}`);
|
|
187
|
-
|
|
188
|
-
await
|
|
189
|
-
await
|
|
190
|
-
|
|
199
|
+
await rm(this.targetWebRoot, { recursive: true, force: true });
|
|
200
|
+
await mkdir(this.targetWebRoot, { recursive: true });
|
|
201
|
+
await Bun.write(
|
|
202
|
+
path.join(this.targetWebRoot, "index.html"),
|
|
203
|
+
this.#injectMobileTargetMeta(await Bun.file(htmlSource).text()),
|
|
204
|
+
);
|
|
191
205
|
}
|
|
192
206
|
#injectMobileTargetMeta(html: string) {
|
|
193
207
|
const basePath = this.target.basePath?.replace(/^\/+|\/+$/g, "") ?? "";
|
|
@@ -198,45 +212,75 @@ export class CapacitorApp {
|
|
|
198
212
|
async #writeCapacitorConfig() {
|
|
199
213
|
await mkdir(this.targetRoot, { recursive: true });
|
|
200
214
|
const appInfoPath = path
|
|
201
|
-
.relative(this.
|
|
215
|
+
.relative(this.app.cwdPath, path.join(this.app.cwdPath, "akan.app.json"))
|
|
202
216
|
.split(path.sep)
|
|
203
217
|
.join("/");
|
|
204
218
|
const baseConfigPath = path
|
|
205
|
-
.relative(this.
|
|
219
|
+
.relative(this.app.cwdPath, path.join(this.app.workspace.cwdPath, "pkgs/akanjs/devkit/capacitor.base.config"))
|
|
206
220
|
.split(path.sep)
|
|
207
221
|
.join("/");
|
|
208
|
-
const content = `import {
|
|
209
|
-
import
|
|
222
|
+
const content = `import type { AppScanResult } from "akanjs/devkit";
|
|
223
|
+
import { withBase } from "${baseConfigPath.startsWith(".") ? baseConfigPath : `./${baseConfigPath}`}";
|
|
224
|
+
import appInfo from "${appInfoPath.startsWith(".") ? appInfoPath : `./${appInfoPath}`}";
|
|
210
225
|
|
|
211
|
-
export default withBase(
|
|
226
|
+
export default withBase(
|
|
227
|
+
(config, target) => ({
|
|
228
|
+
...config,
|
|
229
|
+
webDir: \`mobile/\${target.name}/www\`,
|
|
230
|
+
android: {
|
|
231
|
+
...config.android,
|
|
232
|
+
path: "android",
|
|
233
|
+
},
|
|
234
|
+
ios: {
|
|
235
|
+
...config.ios,
|
|
236
|
+
path: "ios",
|
|
237
|
+
},
|
|
238
|
+
}),
|
|
239
|
+
appInfo as AppScanResult,
|
|
240
|
+
);
|
|
212
241
|
`;
|
|
213
|
-
await Bun.write(path.join(this.
|
|
242
|
+
await Bun.write(path.join(this.app.cwdPath, "capacitor.config.ts"), content);
|
|
214
243
|
}
|
|
215
244
|
async #prepareTargetAssets() {
|
|
216
245
|
if (!this.target.assets) return;
|
|
217
|
-
|
|
218
|
-
await mkdir(assetsDir, { recursive: true });
|
|
246
|
+
await mkdir(this.targetAssetRoot, { recursive: true });
|
|
219
247
|
if (this.target.assets.icon)
|
|
220
|
-
await cp(path.join(this.app.cwdPath, this.target.assets.icon), path.join(
|
|
248
|
+
await cp(path.join(this.app.cwdPath, this.target.assets.icon), path.join(this.targetAssetRoot, "icon.png"), {
|
|
249
|
+
force: true,
|
|
250
|
+
});
|
|
221
251
|
if (this.target.assets.splash)
|
|
222
|
-
await cp(path.join(this.app.cwdPath, this.target.assets.splash), path.join(
|
|
252
|
+
await cp(path.join(this.app.cwdPath, this.target.assets.splash), path.join(this.targetAssetRoot, "splash.png"), {
|
|
223
253
|
force: true,
|
|
224
254
|
});
|
|
225
255
|
}
|
|
226
256
|
async #prepareExternalFiles(platform: "ios" | "android") {
|
|
227
257
|
const files = this.target.files?.[platform];
|
|
228
258
|
if (!files) return;
|
|
259
|
+
const platformRoot = path.join(this.app.cwdPath, platform === "ios" ? this.iosRootPath : this.androidRootPath);
|
|
229
260
|
await Promise.all(
|
|
230
261
|
Object.entries(files).map(async ([to, from]) => {
|
|
231
|
-
const targetPath = path.join(
|
|
262
|
+
const targetPath = path.join(platformRoot, to);
|
|
232
263
|
await mkdir(path.dirname(targetPath), { recursive: true });
|
|
233
264
|
await cp(path.join(this.app.cwdPath, from), targetPath, { force: true });
|
|
234
265
|
}),
|
|
235
266
|
);
|
|
236
267
|
}
|
|
237
|
-
async #generateAssets() {
|
|
268
|
+
async #generateAssets({ operation, env }: Pick<RunConfig, "operation" | "env">) {
|
|
238
269
|
if (!this.target.assets) return;
|
|
239
|
-
await this.#
|
|
270
|
+
await this.#spawnMobile(
|
|
271
|
+
"npx",
|
|
272
|
+
[
|
|
273
|
+
"@capacitor/assets",
|
|
274
|
+
"generate",
|
|
275
|
+
"--assetPath",
|
|
276
|
+
path.posix.join(this.targetRootPath, "assets"),
|
|
277
|
+
"--iosProject",
|
|
278
|
+
this.iosProjectPath,
|
|
279
|
+
"--androidProject",
|
|
280
|
+
this.androidRootPath,
|
|
281
|
+
],
|
|
282
|
+
{ operation, env },
|
|
283
|
+
);
|
|
240
284
|
}
|
|
241
285
|
async #applyIosMetadata() {
|
|
242
286
|
this.project.ios.setBundleId("App", "Debug", this.target.appId);
|
|
@@ -291,16 +335,26 @@ export default withBase((config) => config, appInfo, "${this.target.name}");
|
|
|
291
335
|
}
|
|
292
336
|
}
|
|
293
337
|
#commandEnv(operation: "local" | "release", env: "local" | "debug" | "develop" | "main") {
|
|
294
|
-
return {
|
|
295
|
-
...process.env,
|
|
338
|
+
return this.app.getCommandEnv({
|
|
296
339
|
APP_OPERATION_MODE: operation,
|
|
297
|
-
AKAN_PUBLIC_OPERATION_MODE:
|
|
340
|
+
AKAN_PUBLIC_OPERATION_MODE: env === "local" ? "local" : "cloud",
|
|
298
341
|
AKAN_PUBLIC_ENV: env,
|
|
299
342
|
AKAN_MOBILE_TARGET: this.target.name,
|
|
300
|
-
};
|
|
343
|
+
});
|
|
301
344
|
}
|
|
302
345
|
async #spawn(command: string, args: string[] = [], options: Parameters<AppExecutor["spawn"]>[2] = {}) {
|
|
303
|
-
return await this.app.spawn(command, args, { cwd: this.
|
|
346
|
+
return await this.app.spawn(command, args, { cwd: this.app.cwdPath, ...options });
|
|
347
|
+
}
|
|
348
|
+
async #spawnMobile(
|
|
349
|
+
command: string,
|
|
350
|
+
args: string[] = [],
|
|
351
|
+
{ operation, env }: Pick<RunConfig, "operation" | "env">,
|
|
352
|
+
options: Parameters<AppExecutor["spawn"]>[2] = {},
|
|
353
|
+
) {
|
|
354
|
+
return await this.#spawn(command, args, {
|
|
355
|
+
...options,
|
|
356
|
+
env: { ...this.#commandEnv(operation, env), ...options.env },
|
|
357
|
+
});
|
|
304
358
|
}
|
|
305
359
|
async addCamera() {
|
|
306
360
|
await this.#setPermissionInIos({
|
|
@@ -36,9 +36,10 @@ const resolveMobileTargetByBasePath = (
|
|
|
36
36
|
const [template] = targets;
|
|
37
37
|
if (!template) return undefined;
|
|
38
38
|
return {
|
|
39
|
-
name:
|
|
39
|
+
name: normalizedBasePath,
|
|
40
40
|
config: {
|
|
41
41
|
...template.config,
|
|
42
|
+
name: normalizedBasePath,
|
|
42
43
|
basePath: normalizedBasePath,
|
|
43
44
|
},
|
|
44
45
|
};
|
package/devkit/scanInfo.ts
CHANGED
package/package.json
CHANGED
package/server/akanApp.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { mkdir, rm } from "node:fs/promises";
|
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { Logger } from "akanjs/common";
|
|
4
4
|
import type { AkanChildRole, AkanChildStatus, AkanIpcMessage, AkanMetricsReport, AkanUpstream } from "akanjs/service";
|
|
5
|
+
import { isTraceEnabled } from "akanjs/signal";
|
|
5
6
|
import type { BuilderMessage, BuilderReq, BuilderRes } from "./artifact";
|
|
6
7
|
import { RotatingLogWriter } from "./logging/rotatingLogWriter";
|
|
7
8
|
import { ProcessMetricsCollector } from "./processMetricsCollector";
|
|
@@ -85,6 +86,7 @@ export class AkanApp {
|
|
|
85
86
|
readonly #builderReqMap = new Map<number, { childIdx: number; childLocalId: number }>();
|
|
86
87
|
#server: Bun.Server<GatewayWsData> | null = null;
|
|
87
88
|
#rrIdx = 0;
|
|
89
|
+
#federationChildCache: ChildState[] | null = null;
|
|
88
90
|
#snapshotTimer: Timer | null = null;
|
|
89
91
|
#healthTimer: Timer | null = null;
|
|
90
92
|
#metricsTimer: Timer | null = null;
|
|
@@ -93,6 +95,9 @@ export class AkanApp {
|
|
|
93
95
|
readonly #childOutputBuffers = new Map<string, string>();
|
|
94
96
|
static readonly #ansiPattern = new RegExp(`${String.fromCharCode(27)}\\[[0-?]*[ -/]*[@-~]`, "g");
|
|
95
97
|
#gatewayMetrics: AkanMetricsReport = {};
|
|
98
|
+
#proxyHopCount = 0;
|
|
99
|
+
#proxyHopSumMs = 0;
|
|
100
|
+
#proxyHopMaxMs = 0;
|
|
96
101
|
#resolveStopped: (() => void) | null = null;
|
|
97
102
|
#exitAfterStop = false;
|
|
98
103
|
#stopping = false;
|
|
@@ -248,6 +253,7 @@ export class AkanApp {
|
|
|
248
253
|
lastRestartAt: previous?.lastRestartAt,
|
|
249
254
|
lastRestartReason: previous?.lastRestartReason,
|
|
250
255
|
});
|
|
256
|
+
this.#invalidateFederationChildCache();
|
|
251
257
|
this.#pipeOutput(idx, role, proc.stdout, "stdout");
|
|
252
258
|
this.#pipeOutput(idx, role, proc.stderr, "stderr");
|
|
253
259
|
proc.exited.then((code) => this.#handleChildExit(idx, proc, code));
|
|
@@ -258,6 +264,7 @@ export class AkanApp {
|
|
|
258
264
|
if (!child || child.proc !== proc) return;
|
|
259
265
|
child.status = "exited";
|
|
260
266
|
child.lastExitCode = code;
|
|
267
|
+
this.#invalidateFederationChildCache();
|
|
261
268
|
this.#removeChildRooms(idx);
|
|
262
269
|
if (this.#stopping) return;
|
|
263
270
|
void this.#scheduleChildRestart(child, proc, `exit:${code ?? "unknown"}`);
|
|
@@ -276,6 +283,7 @@ export class AkanApp {
|
|
|
276
283
|
child.status = reason === "health-timeout" ? "unhealthy" : "exited";
|
|
277
284
|
child.upstream = undefined;
|
|
278
285
|
child.healthPath = undefined;
|
|
286
|
+
this.#invalidateFederationChildCache();
|
|
279
287
|
child.lastRestartReason = reason;
|
|
280
288
|
child.lastRestartAt = Date.now();
|
|
281
289
|
this.#removeChildRooms(child.idx);
|
|
@@ -403,6 +411,7 @@ export class AkanApp {
|
|
|
403
411
|
const url = new URL(req.url);
|
|
404
412
|
if (url.pathname === "/_akan/app/health") return Response.json(this.#getHealthStatus());
|
|
405
413
|
if (url.pathname === "/_akan/app/metrics") return Response.json(this.#getMetricsStatus());
|
|
414
|
+
if (url.pathname === "/_akan/bench/ping") return new Response("ok");
|
|
406
415
|
if (this.#isWebSocketPath(url.pathname)) return this.#upgradeWebSocket(req, server);
|
|
407
416
|
const assetResponse = await this.#serveImmutableArtifact(req, url);
|
|
408
417
|
if (assetResponse) return assetResponse;
|
|
@@ -526,6 +535,13 @@ export class AkanApp {
|
|
|
526
535
|
rooms: this.#roomChildren.size,
|
|
527
536
|
sockets: this.#socketRooms.size,
|
|
528
537
|
gateway: this.#gatewayMetrics,
|
|
538
|
+
proxyHop: this.#proxyHopCount
|
|
539
|
+
? {
|
|
540
|
+
count: this.#proxyHopCount,
|
|
541
|
+
meanMs: Math.round((this.#proxyHopSumMs / this.#proxyHopCount) * 1000) / 1000,
|
|
542
|
+
maxMs: Math.round(this.#proxyHopMaxMs * 1000) / 1000,
|
|
543
|
+
}
|
|
544
|
+
: null,
|
|
529
545
|
children: [...this.#children.values()].map((child) => ({
|
|
530
546
|
idx: child.idx,
|
|
531
547
|
role: child.role,
|
|
@@ -551,6 +567,8 @@ export class AkanApp {
|
|
|
551
567
|
const headers = this.#makeProxyHeaders(req, child.idx);
|
|
552
568
|
child.metrics.activeRequests = (child.metrics.activeRequests ?? 0) + 1;
|
|
553
569
|
child.metrics.totalRequests = (child.metrics.totalRequests ?? 0) + 1;
|
|
570
|
+
const traced = isTraceEnabled();
|
|
571
|
+
const hopStart = traced ? performance.now() : 0;
|
|
554
572
|
try {
|
|
555
573
|
const upstreamRes = await fetch(upstreamUrl, {
|
|
556
574
|
unix: child.upstream.socketPath,
|
|
@@ -563,9 +581,20 @@ export class AkanApp {
|
|
|
563
581
|
return this.#proxyResponse(upstreamRes);
|
|
564
582
|
} finally {
|
|
565
583
|
child.metrics.activeRequests = Math.max(0, (child.metrics.activeRequests ?? 1) - 1);
|
|
584
|
+
if (traced) this.#recordProxyHop(performance.now() - hopStart);
|
|
566
585
|
}
|
|
567
586
|
}
|
|
568
587
|
|
|
588
|
+
/**
|
|
589
|
+
* Gateway-observed upstream round-trip time. The pure proxy overhead is this value
|
|
590
|
+
* minus the child handler time captured in the per-request trace.
|
|
591
|
+
*/
|
|
592
|
+
#recordProxyHop(durationMs: number) {
|
|
593
|
+
this.#proxyHopCount += 1;
|
|
594
|
+
this.#proxyHopSumMs += durationMs;
|
|
595
|
+
this.#proxyHopMaxMs = Math.max(this.#proxyHopMaxMs, durationMs);
|
|
596
|
+
}
|
|
597
|
+
|
|
569
598
|
#proxyResponse(upstreamRes: Response): Response {
|
|
570
599
|
const headers = new Headers(upstreamRes.headers);
|
|
571
600
|
|
|
@@ -652,27 +681,36 @@ export class AkanApp {
|
|
|
652
681
|
#makeProxyHeaders(req: Request, childIdx: number) {
|
|
653
682
|
const headers = new Headers(req.headers);
|
|
654
683
|
for (const key of AkanApp.#hopByHopHeaders) headers.delete(key);
|
|
655
|
-
const url = new URL(req.url);
|
|
656
684
|
const forwardedFor = headers.get("x-forwarded-for");
|
|
657
685
|
const clientAddress = headers.get("x-real-ip") ?? "127.0.0.1";
|
|
686
|
+
const host = headers.get("host");
|
|
658
687
|
headers.set("x-forwarded-for", forwardedFor ? `${forwardedFor}, ${clientAddress}` : clientAddress);
|
|
659
|
-
headers.set("x-forwarded-host",
|
|
660
|
-
headers.set("x-forwarded-proto", url.
|
|
688
|
+
headers.set("x-forwarded-host", host ?? new URL(req.url).host);
|
|
689
|
+
headers.set("x-forwarded-proto", req.url.startsWith("https:") ? "https" : "http");
|
|
661
690
|
headers.set("x-akan-child-idx", String(childIdx));
|
|
662
|
-
headers.
|
|
691
|
+
if (!headers.has("x-request-id") && process.env.AKAN_BENCH_SKIP_REQUEST_ID !== "1") {
|
|
692
|
+
headers.set("x-request-id", crypto.randomUUID());
|
|
693
|
+
}
|
|
663
694
|
headers.set("host", "akan-child");
|
|
664
695
|
return headers;
|
|
665
696
|
}
|
|
666
697
|
|
|
698
|
+
#invalidateFederationChildCache() {
|
|
699
|
+
this.#federationChildCache = null;
|
|
700
|
+
}
|
|
701
|
+
|
|
667
702
|
#pickFederationChild() {
|
|
668
|
-
const candidates =
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
child
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
703
|
+
const candidates =
|
|
704
|
+
this.#federationChildCache ??
|
|
705
|
+
[...this.#children.values()].filter(
|
|
706
|
+
(child) =>
|
|
707
|
+
(child.role === "federation" || child.role === "all") &&
|
|
708
|
+
child.ready &&
|
|
709
|
+
child.status !== "unhealthy" &&
|
|
710
|
+
child.status !== "exited" &&
|
|
711
|
+
!child.proc.killed,
|
|
712
|
+
);
|
|
713
|
+
this.#federationChildCache = candidates;
|
|
676
714
|
if (candidates.length === 0) return null;
|
|
677
715
|
const child = candidates[this.#rrIdx % candidates.length];
|
|
678
716
|
this.#rrIdx++;
|
|
@@ -761,6 +799,7 @@ export class AkanApp {
|
|
|
761
799
|
child.lastPongAt = Date.now();
|
|
762
800
|
child.restartAttempts = 0;
|
|
763
801
|
child.restartPending = false;
|
|
802
|
+
this.#invalidateFederationChildCache();
|
|
764
803
|
if ([...this.#children.values()].every((item) => item.ready)) {
|
|
765
804
|
process.send?.({ type: "backend-ready", pid: process.pid } satisfies AkanIpcMessage);
|
|
766
805
|
this.logger.verbose(`All ${this.#children.size} child process(es) are ready`);
|
|
@@ -772,6 +811,7 @@ export class AkanApp {
|
|
|
772
811
|
if (!child) return;
|
|
773
812
|
child.status = "healthy";
|
|
774
813
|
child.lastPongAt = Date.now();
|
|
814
|
+
this.#invalidateFederationChildCache();
|
|
775
815
|
}
|
|
776
816
|
|
|
777
817
|
#deliverPubsub(originIdx: number, message: Extract<AkanIpcMessage, { type: "pubsub.publish" }>) {
|
|
@@ -903,6 +943,7 @@ export class AkanApp {
|
|
|
903
943
|
if (child.proc.killed || child.status === "exited") continue;
|
|
904
944
|
if (child.lastPongAt && now - child.lastPongAt > 5_000) {
|
|
905
945
|
child.status = "unhealthy";
|
|
946
|
+
this.#invalidateFederationChildCache();
|
|
906
947
|
void this.#scheduleChildRestart(child, child.proc, "health-timeout");
|
|
907
948
|
return;
|
|
908
949
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { AkanMetricsReport } from "akanjs/service";
|
|
2
|
+
import { getTraceSnapshot, isTraceEnabled } from "akanjs/signal";
|
|
2
3
|
|
|
3
4
|
type BunJscHeapStats = {
|
|
4
5
|
heapSize?: number;
|
|
@@ -8,19 +9,83 @@ type BunJscHeapStats = {
|
|
|
8
9
|
protectedObjectCount?: number;
|
|
9
10
|
};
|
|
10
11
|
|
|
12
|
+
/**
|
|
13
|
+
* Samples event-loop scheduling delay by measuring how late a fixed-interval timer
|
|
14
|
+
* actually fires. The sample window is summarized and reset on each metrics report,
|
|
15
|
+
* so values reflect recent load rather than process lifetime.
|
|
16
|
+
*/
|
|
17
|
+
class EventLoopLagMonitor {
|
|
18
|
+
static readonly #maxSamples = 600;
|
|
19
|
+
#timer: ReturnType<typeof setInterval> | null = null;
|
|
20
|
+
#intervalMs = 500;
|
|
21
|
+
#lastTickAt = 0;
|
|
22
|
+
#samples: number[] = [];
|
|
23
|
+
#maxMs = 0;
|
|
24
|
+
|
|
25
|
+
start(intervalMs = 500): void {
|
|
26
|
+
if (this.#timer) return;
|
|
27
|
+
this.#intervalMs = intervalMs;
|
|
28
|
+
this.#lastTickAt = performance.now();
|
|
29
|
+
this.#timer = setInterval(() => {
|
|
30
|
+
const now = performance.now();
|
|
31
|
+
const lag = Math.max(0, now - this.#lastTickAt - this.#intervalMs);
|
|
32
|
+
this.#lastTickAt = now;
|
|
33
|
+
this.#maxMs = Math.max(this.#maxMs, lag);
|
|
34
|
+
if (this.#samples.length < EventLoopLagMonitor.#maxSamples) this.#samples.push(lag);
|
|
35
|
+
else this.#samples[Math.floor(Math.random() * EventLoopLagMonitor.#maxSamples)] = lag;
|
|
36
|
+
}, intervalMs);
|
|
37
|
+
|
|
38
|
+
(this.#timer as { unref?: () => void }).unref?.();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Summarize the current window and reset it. */
|
|
42
|
+
snapshotAndReset(): { meanMs: number; p99Ms: number; maxMs: number } | null {
|
|
43
|
+
if (this.#samples.length === 0) return null;
|
|
44
|
+
const sorted = [...this.#samples].sort((a, b) => a - b);
|
|
45
|
+
const sum = sorted.reduce((acc, v) => acc + v, 0);
|
|
46
|
+
const p99Index = Math.min(sorted.length - 1, Math.floor(0.99 * sorted.length));
|
|
47
|
+
const result = {
|
|
48
|
+
meanMs: round(sum / sorted.length),
|
|
49
|
+
p99Ms: round(sorted[p99Index] ?? 0),
|
|
50
|
+
maxMs: round(this.#maxMs),
|
|
51
|
+
};
|
|
52
|
+
this.#samples = [];
|
|
53
|
+
this.#maxMs = 0;
|
|
54
|
+
return result;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const round = (value: number, digits = 3): number => {
|
|
59
|
+
const factor = 10 ** digits;
|
|
60
|
+
return Math.round(value * factor) / factor;
|
|
61
|
+
};
|
|
62
|
+
|
|
11
63
|
export class ProcessMetricsCollector {
|
|
12
64
|
static readonly #defaultMemoryLogIntervalMs = 60_000;
|
|
65
|
+
static readonly #lagMonitor = new EventLoopLagMonitor();
|
|
13
66
|
|
|
14
67
|
static parseMemoryLogIntervalMs(value = process.env.AKAN_MEMORY_LOG_INTERVAL_MS) {
|
|
15
68
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
16
69
|
return Number.isFinite(parsed) && parsed > 0 ? parsed : ProcessMetricsCollector.#defaultMemoryLogIntervalMs;
|
|
17
70
|
}
|
|
18
71
|
|
|
72
|
+
/** Begin sampling event-loop lag. Idempotent; safe to call from each server role. */
|
|
73
|
+
static startEventLoopLagMonitor(intervalMs = 500): void {
|
|
74
|
+
ProcessMetricsCollector.#lagMonitor.start(intervalMs);
|
|
75
|
+
}
|
|
76
|
+
|
|
19
77
|
static async collect(extra: AkanMetricsReport = {}): Promise<AkanMetricsReport> {
|
|
20
|
-
|
|
78
|
+
ProcessMetricsCollector.#lagMonitor.start();
|
|
79
|
+
let gcDurationMs: number | undefined;
|
|
80
|
+
if (process.env.AKAN_MEMORY_GC_ON_REPORT === "1") {
|
|
81
|
+
const gcStart = performance.now();
|
|
82
|
+
Bun.gc(true);
|
|
83
|
+
gcDurationMs = round(performance.now() - gcStart);
|
|
84
|
+
}
|
|
21
85
|
const memory = process.memoryUsage();
|
|
22
86
|
const resourceUsage = process.resourceUsage?.();
|
|
23
87
|
const jsc = await ProcessMetricsCollector.#collectJscHeapStats();
|
|
88
|
+
const lag = ProcessMetricsCollector.#lagMonitor.snapshotAndReset();
|
|
24
89
|
return {
|
|
25
90
|
pid: process.pid,
|
|
26
91
|
reportedAt: Date.now(),
|
|
@@ -45,6 +110,15 @@ export class ProcessMetricsCollector {
|
|
|
45
110
|
jscProtectedObjectCount: jsc.protectedObjectCount,
|
|
46
111
|
}
|
|
47
112
|
: {}),
|
|
113
|
+
...(lag
|
|
114
|
+
? {
|
|
115
|
+
eventLoopLagMeanMs: lag.meanMs,
|
|
116
|
+
eventLoopLagP99Ms: lag.p99Ms,
|
|
117
|
+
eventLoopLagMaxMs: lag.maxMs,
|
|
118
|
+
}
|
|
119
|
+
: {}),
|
|
120
|
+
...(gcDurationMs !== undefined ? { gcDurationMs } : {}),
|
|
121
|
+
...(isTraceEnabled() ? { trace: getTraceSnapshot() } : {}),
|
|
48
122
|
...extra,
|
|
49
123
|
};
|
|
50
124
|
}
|
|
@@ -64,6 +138,10 @@ export class ProcessMetricsCollector {
|
|
|
64
138
|
...(metrics.jscHeapSizeBytes !== undefined
|
|
65
139
|
? [`jscHeap=${ProcessMetricsCollector.formatBytes(metrics.jscHeapSizeBytes)}`]
|
|
66
140
|
: []),
|
|
141
|
+
...(metrics.eventLoopLagMeanMs !== undefined
|
|
142
|
+
? [`elLag=${metrics.eventLoopLagMeanMs}/${metrics.eventLoopLagP99Ms ?? 0}/${metrics.eventLoopLagMaxMs ?? 0}ms`]
|
|
143
|
+
: []),
|
|
144
|
+
...(metrics.gcDurationMs !== undefined ? [`gc=${metrics.gcDurationMs}ms`] : []),
|
|
67
145
|
].join(" ");
|
|
68
146
|
}
|
|
69
147
|
|