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.
Files changed (45) hide show
  1. package/README.ko.md +1 -1
  2. package/README.md +1 -1
  3. package/cli/application/application.command.ts +4 -1
  4. package/cli/application/application.runner.ts +5 -7
  5. package/cli/build.ts +1 -0
  6. package/cli/index.js +114 -74
  7. package/constant/serialize.ts +1 -1
  8. package/devkit/capacitor.base.config.ts +18 -4
  9. package/devkit/capacitorApp.ts +118 -64
  10. package/devkit/mobile/mobileTarget.ts +2 -1
  11. package/devkit/scanInfo.ts +1 -0
  12. package/package.json +1 -1
  13. package/server/akanApp.ts +53 -12
  14. package/server/processMetricsCollector.ts +79 -1
  15. package/server/resolver/database.resolver.ts +82 -31
  16. package/server/resolver/signal.resolver.ts +67 -28
  17. package/service/ipcTypes.ts +5 -0
  18. package/service/predefinedAdaptor/database.adaptor.ts +95 -27
  19. package/service/predefinedAdaptor/solidSqlite.ts +7 -7
  20. package/service/predefinedAdaptor/storage.adaptor.ts +35 -9
  21. package/signal/index.ts +1 -0
  22. package/signal/middleware.ts +5 -1
  23. package/signal/signalContext.ts +85 -31
  24. package/signal/trace.ts +279 -0
  25. package/types/devkit/capacitorApp.d.ts +14 -5
  26. package/types/server/processMetricsCollector.d.ts +2 -0
  27. package/types/service/ipcTypes.d.ts +5 -0
  28. package/types/service/predefinedAdaptor/database.adaptor.d.ts +26 -32
  29. package/types/service/predefinedAdaptor/solidSqlite.d.ts +3 -3
  30. package/types/service/predefinedAdaptor/storage.adaptor.d.ts +8 -2
  31. package/types/signal/index.d.ts +1 -0
  32. package/types/signal/signalContext.d.ts +4 -1
  33. package/types/signal/trace.d.ts +97 -0
  34. package/types/ui/Signal/style.d.ts +15 -0
  35. package/ui/Signal/Arg.tsx +22 -15
  36. package/ui/Signal/Doc.tsx +28 -21
  37. package/ui/Signal/Listener.tsx +15 -39
  38. package/ui/Signal/Message.tsx +32 -50
  39. package/ui/Signal/Object.tsx +16 -13
  40. package/ui/Signal/PubSub.tsx +29 -47
  41. package/ui/Signal/Response.tsx +7 -17
  42. package/ui/Signal/RestApi.tsx +41 -57
  43. package/ui/Signal/WebSocket.tsx +1 -1
  44. package/ui/Signal/style.ts +36 -0
  45. package/webkit/useCsrValues.ts +147 -37
@@ -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.targetRoot = path.join(this.app.cwdPath, "mobile", this.target.name);
27
- this.project = new MobileProject(this.targetRoot, {
28
- android: { path: "android" },
29
- ios: { path: "ios/App" },
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({ platform, regenerate = false }: { platform?: "ios" | "android"; regenerate?: boolean } = {}) {
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.targetRoot, "ios"), { recursive: true, force: true });
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.targetRoot, "android"), { recursive: true, force: true });
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.#spawn("npx", ["cap", "add", "android"]);
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.#spawn("npx", ["cap", "add", "ios"]);
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 }: { regenerate?: boolean } = {}) {
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.#spawn("npx", ["cap", "sync", "ios"]);
82
+ await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation, env });
67
83
  this.app.verbose(`sync completed.`);
68
84
  }
69
- async buildIos(options: { regenerate?: boolean } = {}) {
85
+ async buildIos({ env = "debug", regenerate = false }: { env?: RunConfig["env"]; regenerate?: boolean } = {}) {
70
86
  await this.prepareWww();
71
- await this.#prepareIos(options);
72
- await this.#spawn("npx", ["cap", "build", "ios"], { stdio: "inherit" });
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.#spawn("npx", ["cap", "sync", "ios"]);
93
+ await this.#spawnMobile("npx", ["cap", "sync", "ios"], { operation: "local", env: "local" });
78
94
  }
79
95
  async openIos() {
80
- await this.#spawn("npx", ["cap", "open", "ios"]);
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
- if (operation !== "release") args.push("--live-reload");
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 }: { regenerate?: boolean } = {}) {
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.#spawn("npx", ["cap", "sync", "android"]);
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(`${this.targetRoot}/android/app/build.gradle`);
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(assembleType: "apk" | "aab", options: { regenerate?: boolean } = {}) {
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(options);
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: `${this.targetRoot}/android`,
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.#spawn("npx", ["cap", "open", "android"]);
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
- if (operation !== "release") args.push("--live-reload");
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
- const wwwDir = path.join(this.targetRoot, "www");
188
- await rm(wwwDir, { recursive: true, force: true });
189
- await mkdir(wwwDir, { recursive: true });
190
- await Bun.write(path.join(wwwDir, "index.html"), this.#injectMobileTargetMeta(await Bun.file(htmlSource).text()));
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.targetRoot, path.join(this.app.cwdPath, "akan.app.json"))
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.targetRoot, path.join(this.app.workspace.cwdPath, "pkgs/akanjs/devkit/capacitor.base.config"))
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 { withBase } from "${baseConfigPath.startsWith(".") ? baseConfigPath : `./${baseConfigPath}`}";
209
- import appInfo from "${appInfoPath}";
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((config) => config, appInfo, "${this.target.name}");
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.targetRoot, "capacitor.config.ts"), content);
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
- const assetsDir = path.join(this.targetRoot, "assets");
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(assetsDir, "icon.png"), { force: true });
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(assetsDir, "splash.png"), {
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(this.targetRoot, platform, to);
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.#spawn("npx", ["@capacitor/assets", "generate"]);
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: operation,
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.targetRoot, ...options });
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: template.name,
39
+ name: normalizedBasePath,
40
40
  config: {
41
41
  ...template.config,
42
+ name: normalizedBasePath,
42
43
  basePath: normalizedBasePath,
43
44
  },
44
45
  };
@@ -59,6 +59,7 @@ const appRootAllowedDirs = new Set([
59
59
  "env",
60
60
  "ios",
61
61
  "lib",
62
+ "mobile",
62
63
  "page",
63
64
  "private",
64
65
  "public",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.0.6",
3
+ "version": "2.0.7",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "bin": {
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", headers.get("host") ?? url.host);
660
- headers.set("x-forwarded-proto", url.protocol.replace(":", ""));
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.set("x-request-id", headers.get("x-request-id") ?? crypto.randomUUID());
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 = [...this.#children.values()].filter(
669
- (child) =>
670
- (child.role === "federation" || child.role === "all") &&
671
- child.ready &&
672
- child.status !== "unhealthy" &&
673
- child.status !== "exited" &&
674
- !child.proc.killed,
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
- if (process.env.AKAN_MEMORY_GC_ON_REPORT === "1") Bun.gc(true);
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