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
@@ -16,6 +16,7 @@ export interface UploadRequest {
16
16
  meta?: { [key: string]: string };
17
17
  rename?: string;
18
18
  host?: string;
19
+ access?: "public" | "private";
19
20
  }
20
21
  export interface CopyRequest {
21
22
  bucket: string;
@@ -29,6 +30,7 @@ export interface UploadFromStreamRequest {
29
30
  body: ReadableStream;
30
31
  mimetype: string;
31
32
  root?: string;
33
+ access?: "public" | "private";
32
34
  updateProgress: (progress: { loaded?: number; total?: number; part?: number }) => void;
33
35
  uploadSuccess: (url: string) => void;
34
36
  }
@@ -47,17 +49,22 @@ export interface StorageAdaptor {
47
49
  saveData(request: DownloadRequest): Promise<LocalFilePath>;
48
50
  copyData(request: CopyRequest): Promise<string>;
49
51
  deleteData(url: string): Promise<boolean>;
52
+ deleteDataByPath(path: string): Promise<boolean>;
50
53
  }
51
54
 
52
55
  export interface BlobStorageOptions extends BaseEnv {
53
- blobStorage?: { baseDir?: string; urlPrefix?: string };
56
+ blobStorage?: { baseDir?: string; privateBaseDir?: string; urlPrefix?: string };
54
57
  }
55
58
 
56
59
  export class BlobStorage
57
60
  extends adapt("blobStorage", ({ env }) => ({
58
61
  root: env(
59
62
  ({ appName, blobStorage = { baseDir: "local", urlPrefix: "/backend/localFile/getBlob" } }: BlobStorageOptions) =>
60
- `${process.env.AKAN_WORKSPACE_ROOT ?? "."}/${blobStorage.baseDir}/${appName}/backend`,
63
+ `${process.env.AKAN_WORKSPACE_ROOT ?? "."}/${blobStorage.baseDir ?? "local"}/${appName}/backend`,
64
+ ),
65
+ privateRoot: env(
66
+ ({ appName, blobStorage = { privateBaseDir: "local" } }: BlobStorageOptions) =>
67
+ `${process.env.AKAN_WORKSPACE_ROOT ?? "."}/${blobStorage.privateBaseDir ?? "local"}/${appName}/server-private`,
61
68
  ),
62
69
  urlPrefix: env(
63
70
  ({ blobStorage = { urlPrefix: "/backend/localFile/getBlob" } }: BlobStorageOptions) => blobStorage.urlPrefix,
@@ -68,12 +75,15 @@ export class BlobStorage
68
75
  #localPathToUrl(path: string) {
69
76
  return `${this.urlPrefix}/${path}`;
70
77
  }
78
+ #resolveFilePath(path: string) {
79
+ return path.startsWith("private/") ? `${this.privateRoot}/${path}` : `${this.root}/${path}`;
80
+ }
71
81
  async readData(path: string): Promise<ReadableStream> {
72
- const filePath = `${this.root}/${path}`;
82
+ const filePath = this.#resolveFilePath(path);
73
83
  return Bun.file(filePath).stream();
74
84
  }
75
85
  async readDataAsJson<T>(path: string) {
76
- const filePath = `${this.root}/${path}`;
86
+ const filePath = this.#resolveFilePath(path);
77
87
  return Bun.file(filePath).json() as T;
78
88
  }
79
89
  async getDataList(prefix?: string) {
@@ -81,14 +91,21 @@ export class BlobStorage
81
91
  const paths = Array.from(new Bun.Glob("*").scanSync({ cwd: dir, onlyFiles: false }));
82
92
  return paths.map((path) => this.#localPathToUrl(path));
83
93
  }
84
- async uploadDataFromLocal({ path, localPath, meta }: UploadRequest) {
85
- const filePath = `${this.root}/${path}`;
94
+ async uploadDataFromLocal({ path, localPath, meta, access = "public" }: UploadRequest) {
95
+ const filePath = access === "private" ? `${this.privateRoot}/${path}` : `${this.root}/${path}`;
86
96
  await Bun.write(filePath, Bun.file(localPath));
87
97
  if (meta) await Bun.write(`${filePath}.meta`, JSON.stringify(meta));
88
98
  return this.#localPathToUrl(path);
89
99
  }
90
- async uploadDataFromStream({ path, body, mimetype, updateProgress, uploadSuccess }: UploadFromStreamRequest) {
91
- const filePath = `${this.root}/${path}`;
100
+ async uploadDataFromStream({
101
+ path,
102
+ body,
103
+ mimetype,
104
+ updateProgress,
105
+ uploadSuccess,
106
+ access = "public",
107
+ }: UploadFromStreamRequest) {
108
+ const filePath = access === "private" ? `${this.privateRoot}/${path}` : `${this.root}/${path}`;
92
109
  try {
93
110
  await Bun.write(filePath, new Response(body));
94
111
  uploadSuccess(this.#localPathToUrl(path));
@@ -106,12 +123,21 @@ export class BlobStorage
106
123
  await Bun.write(`${this.root}/${pastePath}`, Bun.file(`${this.root}/${copyPath}`));
107
124
  return pastePath;
108
125
  }
126
+ async deleteDataByPath(path: string) {
127
+ try {
128
+ await Bun.file(this.#resolveFilePath(path)).delete();
129
+ return true;
130
+ } catch (error) {
131
+ this.logger.error(error instanceof Error ? error.message : "Unknown error");
132
+ return false;
133
+ }
134
+ }
109
135
  async deleteData(url: string) {
110
136
  try {
111
137
  const basePath = this.#localPathToUrl("");
112
138
  if (!url.startsWith(basePath)) throw new Error("Invalid Base URL, Unable to delete data");
113
139
  const path = url.replace(basePath, "");
114
- await Bun.file(`${this.root}/${path}`).delete();
140
+ await this.deleteDataByPath(path);
115
141
  return true;
116
142
  } catch (error) {
117
143
  this.logger.error(error instanceof Error ? error.message : "Unknown error");
package/signal/index.ts CHANGED
@@ -15,4 +15,5 @@ export * from "./signalContext";
15
15
  export * from "./signalRegistry";
16
16
  export * from "./slice";
17
17
  export * from "./sliceInfo";
18
+ export * from "./trace";
18
19
  export * from "./types";
@@ -2,6 +2,7 @@ import type { BaseEnv, Cls, PromiseOrObject } from "akanjs/base";
2
2
  import { type CacheAdaptor, CacheAdaptorRole } from "akanjs/service";
3
3
  import dayjs from "dayjs";
4
4
  import type { SignalContext } from "./signalContext";
5
+ import { traceCache } from "./trace";
5
6
 
6
7
  export interface Middleware<Env extends BaseEnv = BaseEnv> {
7
8
  use(env: Env): PromiseOrObject<(context: SignalContext, next: () => Promise<unknown>) => PromiseOrObject<unknown>>;
@@ -52,12 +53,15 @@ export class Cache extends middleware("cache") {
52
53
  if (cached) {
53
54
  context.adaptor.logger.debug(`Cache hit ${context.key}`);
54
55
  try {
55
- return JSON.parse(cached);
56
+ const parsed = JSON.parse(cached);
57
+ traceCache(true);
58
+ return parsed;
56
59
  } catch (parseError) {
57
60
  context.adaptor.logger.warn(`Cache parse error ${context.key}: ${String(parseError)}`);
58
61
  await cache.delete(topic, key);
59
62
  }
60
63
  }
64
+ traceCache(false);
61
65
 
62
66
  const result = await next();
63
67
 
@@ -18,6 +18,7 @@ import type { Adaptor, AdaptorCls, DatabaseService, InjectRegistry, LiveRegistry
18
18
  import type { Internal, InternalCls, InternalInfo, MiddlewareCls } from ".";
19
19
  import type { EndpointInfo } from "./endpointInfo";
20
20
  import { Exception } from "./exception";
21
+ import { isTraceEnabled, runWithTrace, SignalTrace, traceSpan } from "./trace";
21
22
 
22
23
  export type SignalTransportType = "http" | "websocket";
23
24
 
@@ -54,6 +55,7 @@ export class SignalContext<
54
55
  adaptor: Adaptor;
55
56
  args: unknown[] = [];
56
57
  internalArgs: unknown[] = [];
58
+ trace: SignalTrace | null = null;
57
59
  #registry: InjectRegistry;
58
60
  #env: Env;
59
61
  #live: LiveRegistry;
@@ -87,6 +89,7 @@ export class SignalContext<
87
89
  this.#env = env;
88
90
  this.#live = live;
89
91
  this.#middleware = middleware;
92
+ if (isTraceEnabled()) this.trace = new SignalTrace(key, endpointInfo.type);
90
93
  }
91
94
 
92
95
  getAdaptor<T extends Adaptor>(adaptorCls: AdaptorCls<T>): T {
@@ -97,11 +100,18 @@ export class SignalContext<
97
100
  return instance as T;
98
101
  }
99
102
  async init() {
100
- this.args = await this.ctx.getArgs(this.endpointInfo);
103
+ if (this.trace) {
104
+ const start = performance.now();
105
+ this.args = await this.ctx.getArgs(this.endpointInfo);
106
+ this.trace.recordSpan("argParse", performance.now() - start);
107
+ } else {
108
+ this.args = await this.ctx.getArgs(this.endpointInfo);
109
+ }
101
110
  return this;
102
111
  }
103
112
  async #checkGuards() {
104
113
  const guards = this.endpointInfo.signalOption.guards ?? [];
114
+ if (guards.length === 0) return;
105
115
  for (const GuardCls of guards) {
106
116
  const guard = new GuardCls();
107
117
  const canPass = guard.canPass(this);
@@ -109,41 +119,72 @@ export class SignalContext<
109
119
  }
110
120
  }
111
121
  async exec() {
122
+ if (!this.trace) return await this.#exec();
123
+ return await runWithTrace(this.trace, async () => {
124
+ try {
125
+ return await this.#exec();
126
+ } finally {
127
+ this.trace?.finalize();
128
+ }
129
+ });
130
+ }
131
+ async #exec() {
112
132
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
113
- const middlewares = [...this.#middleware.values(), ...(this.endpointInfo.signalOption.middlewares ?? [])];
133
+ const endpointMiddlewares = this.endpointInfo.signalOption.middlewares ?? [];
114
134
  const coreExec = async () => {
115
135
  if (!this.endpointInfo.execFn) throw new Exception.Error("Exec function is not set");
116
- await this.#checkGuards();
117
- this.internalArgs = await Promise.all(
118
- this.endpointInfo.internalArgs.map((arg) => {
119
- const argValue = new arg.argRef().getArg(this) ?? null;
120
- if (argValue === null && !arg.option?.nullable)
121
- throw new Exception.Unauthorized(`Internal Argument ${arg.argRef.name} is required`);
122
- return argValue;
123
- }),
136
+ if (this.trace) await traceSpan("guards", () => this.#checkGuards());
137
+ else await this.#checkGuards();
138
+ if (this.endpointInfo.internalArgs.length > 0) {
139
+ this.internalArgs = await Promise.all(
140
+ this.endpointInfo.internalArgs.map((arg) => {
141
+ const argValue = new arg.argRef().getArg(this) ?? null;
142
+ if (argValue === null && !arg.option?.nullable)
143
+ throw new Exception.Unauthorized(`Internal Argument ${arg.argRef.name} is required`);
144
+ return argValue;
145
+ }),
146
+ );
147
+ }
148
+ if (!this.trace) return await this.endpointInfo.execFn.call(this.adaptor, ...this.args, ...this.internalArgs);
149
+ return await traceSpan(
150
+ "handler",
151
+ async () => await this.endpointInfo.execFn?.call(this.adaptor, ...this.args, ...this.internalArgs),
124
152
  );
125
- const result = await this.endpointInfo.execFn.call(this.adaptor, ...this.args, ...this.internalArgs);
126
- return result;
127
153
  };
128
154
  let next = coreExec;
129
- for (let i = middlewares.length - 1; i >= 0; i--) {
130
- const MiddlewareCls = middlewares[i];
131
- if (!MiddlewareCls) continue;
132
- const middleware = new MiddlewareCls();
133
- const currentNext = next;
134
- next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
155
+ if (this.#middleware.size > 0 || endpointMiddlewares.length > 0) {
156
+ const middlewares = [...this.#middleware.values(), ...endpointMiddlewares];
157
+ for (let i = middlewares.length - 1; i >= 0; i--) {
158
+ const MiddlewareCls = middlewares[i];
159
+ if (!MiddlewareCls) continue;
160
+ const middleware = new MiddlewareCls();
161
+ const currentNext = next;
162
+ next = async () => await (await middleware.use(this.getEnv()))(this, currentNext);
163
+ }
135
164
  }
136
- const result = await next();
165
+ const result = this.trace ? await traceSpan("execChain", () => next()) : await next();
137
166
  if (this.endpointInfo.type === "pubsub") return;
138
167
  if (result instanceof Response) return result;
139
- const resolved = await SignalContext.resolveReturn(result, {
140
- signalContext: this,
141
- returnRef: this.endpointInfo.returns.returnRef,
142
- arrDepth: this.endpointInfo.returns.arrDepth,
143
- registry: this.#registry,
144
- live: this.#live,
145
- });
146
- return this.ctx.makeResponse(resolved, this.endpointInfo);
168
+ if (!this.trace) {
169
+ const resolved = await SignalContext.resolveReturn(result, {
170
+ signalContext: this,
171
+ returnRef: this.endpointInfo.returns.returnRef,
172
+ arrDepth: this.endpointInfo.returns.arrDepth,
173
+ registry: this.#registry,
174
+ live: this.#live,
175
+ });
176
+ return this.ctx.makeResponse(resolved, this.endpointInfo);
177
+ }
178
+ const resolved = await traceSpan("resolveReturn", () =>
179
+ SignalContext.resolveReturn(result, {
180
+ signalContext: this,
181
+ returnRef: this.endpointInfo.returns.returnRef,
182
+ arrDepth: this.endpointInfo.returns.arrDepth,
183
+ registry: this.#registry,
184
+ live: this.#live,
185
+ }),
186
+ );
187
+ return await traceSpan("serialize", async () => this.ctx.makeResponse(resolved, this.endpointInfo));
147
188
  }
148
189
  static async try(
149
190
  endpoint: Adaptor,
@@ -257,6 +298,11 @@ export class SignalContext<
257
298
  service: DatabaseService,
258
299
  { arrDepth, nullable }: { arrDepth: number; nullable: boolean },
259
300
  ): Promise<unknown> {
301
+ if (value === null || value === undefined) {
302
+ if (nullable) return null;
303
+ throw new Error(`Document ${value} is not found`);
304
+ }
305
+ if (arrDepth > 0 && Array.isArray(value) && value.length === 0) return [];
260
306
  if (arrDepth === 0)
261
307
  return await service.__load(String(value)).then((doc) => {
262
308
  if (doc === null) {
@@ -300,19 +346,24 @@ export class SignalContext<
300
346
  export class HttpExecutionContext<Appended = unknown> {
301
347
  req: Bun.BunRequest & Appended;
302
348
  res = Response;
303
- url: URL;
349
+ #url: URL | null = null;
304
350
  params: RuntimeRecord = {};
305
351
  searchParams: RuntimeRecord = {};
306
352
  body: RuntimeRecord = {};
307
353
  constructor(req: Bun.BunRequest) {
308
354
  this.req = req as Bun.BunRequest & Appended;
309
- this.url = new URL(req.url);
355
+ }
356
+ get url() {
357
+ if (!this.#url) this.#url = new URL(this.req.url);
358
+ return this.#url;
310
359
  }
311
360
  async getArgs(endpointInfo: EndpointInfo): Promise<unknown[]> {
361
+ if (endpointInfo.args.length === 0) return [];
312
362
  this.params = this.req.params;
313
363
 
314
- const hasUploadArgs = endpointInfo.args.some((arg) => arg.type === "body" && arg.argRef === Upload);
315
- if (endpointInfo.type === "mutation" && this.req.body) {
364
+ const hasBodyArgs = endpointInfo.args.some((arg) => arg.type === "body");
365
+ const hasUploadArgs = hasBodyArgs && endpointInfo.args.some((arg) => arg.type === "body" && arg.argRef === Upload);
366
+ if (endpointInfo.type === "mutation" && hasBodyArgs && this.req.body) {
316
367
  if (hasUploadArgs) {
317
368
  const formData = await this.req.formData();
318
369
  this.body = {};
@@ -361,6 +412,9 @@ export class HttpExecutionContext<Appended = unknown> {
361
412
  }
362
413
  makeResponse(result: unknown, endpointInfo: EndpointInfo) {
363
414
  if (result instanceof Response) return result;
415
+ if (endpointInfo.returns.arrDepth === 0 && PrimitiveRegistry.has(endpointInfo.returns.returnRef as Cls)) {
416
+ return this.res.json(result);
417
+ }
364
418
  const value = serialize(endpointInfo.returns.returnRef, endpointInfo.returns.arrDepth, result, "object", {
365
419
  nullable: endpointInfo.returns.nullable,
366
420
  });
@@ -0,0 +1,279 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+
3
+ /**
4
+ * Lightweight request tracing for Akan signals.
5
+ *
6
+ * Everything here is gated behind the `AKAN_TRACE=1` environment flag so that the
7
+ * production hot path pays nothing when tracing is disabled (see {@link isTraceEnabled}).
8
+ * The goal is internal performance work (per-stage latency breakdown, query counts,
9
+ * cache hit ratio) rather than full distributed tracing.
10
+ */
11
+
12
+ let traceEnabledCache: boolean | null = null;
13
+
14
+ /** Whether request tracing is enabled. Cached after first read. */
15
+ export const isTraceEnabled = (): boolean => {
16
+ if (traceEnabledCache === null) traceEnabledCache = process.env.AKAN_TRACE === "1";
17
+ return traceEnabledCache;
18
+ };
19
+
20
+ /** Override the trace flag at runtime (tests / harness control). */
21
+ export const setTraceEnabled = (enabled: boolean): void => {
22
+ traceEnabledCache = enabled;
23
+ };
24
+
25
+ export interface SpanRecord {
26
+ name: string;
27
+ durationMs: number;
28
+ }
29
+
30
+ const MAX_SPANS_PER_TRACE = 64;
31
+
32
+ /** Per-request trace context. Threaded via {@link AsyncLocalStorage}. */
33
+ export class SignalTrace {
34
+ readonly traceId: string;
35
+ readonly endpointKey: string;
36
+ readonly endpointType: string;
37
+ readonly startedAt: number;
38
+ readonly spans: SpanRecord[] = [];
39
+ dbQueryCount = 0;
40
+ dbQueryMs = 0;
41
+ cacheHits = 0;
42
+ cacheMisses = 0;
43
+ dataLoaderBatchCount = 0;
44
+ dataLoaderKeyCount = 0;
45
+ #finalized = false;
46
+
47
+ constructor(endpointKey: string, endpointType: string) {
48
+ this.endpointKey = endpointKey;
49
+ this.endpointType = endpointType;
50
+ this.startedAt = performance.now();
51
+ this.traceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`;
52
+ }
53
+
54
+ recordSpan(name: string, durationMs: number): void {
55
+ if (this.spans.length >= MAX_SPANS_PER_TRACE) return;
56
+ this.spans.push({ name, durationMs });
57
+ }
58
+
59
+ countDbQuery(durationMs: number): void {
60
+ this.dbQueryCount += 1;
61
+ this.dbQueryMs += durationMs;
62
+ }
63
+
64
+ countCache(hit: boolean): void {
65
+ if (hit) this.cacheHits += 1;
66
+ else this.cacheMisses += 1;
67
+ }
68
+
69
+ countDataLoaderBatch(keyCount: number): void {
70
+ this.dataLoaderBatchCount += 1;
71
+ this.dataLoaderKeyCount += keyCount;
72
+ }
73
+
74
+ finalize(): void {
75
+ if (this.#finalized) return;
76
+ this.#finalized = true;
77
+ const totalMs = performance.now() - this.startedAt;
78
+ this.recordSpan("total", totalMs);
79
+ traceAggregator.ingest(this);
80
+ maybeFlushTraceFile();
81
+ }
82
+ }
83
+
84
+ const traceProcessStore = process as unknown as {
85
+ __akanTraceAls?: AsyncLocalStorage<SignalTrace>;
86
+ __akanTraceAggregator?: TraceAggregator;
87
+ };
88
+
89
+ let alsInstance = traceProcessStore.__akanTraceAls;
90
+ if (!alsInstance) {
91
+ alsInstance = new AsyncLocalStorage<SignalTrace>();
92
+ traceProcessStore.__akanTraceAls = alsInstance;
93
+ }
94
+ const als = alsInstance;
95
+
96
+ export const getCurrentTrace = (): SignalTrace | undefined => als.getStore();
97
+
98
+ /** Run `fn` with `trace` as the ambient request trace. */
99
+ export const runWithTrace = <T>(trace: SignalTrace, fn: () => T): T => als.run(trace, fn);
100
+
101
+ /**
102
+ * Time an async stage under the current trace. When tracing is off (or no trace is
103
+ * active) this is a thin passthrough with no measurement overhead.
104
+ */
105
+ export const traceSpan = async <T>(name: string, fn: () => Promise<T>): Promise<T> => {
106
+ const trace = getCurrentTrace();
107
+ if (!trace) return await fn();
108
+ const start = performance.now();
109
+ try {
110
+ return await fn();
111
+ } finally {
112
+ trace.recordSpan(name, performance.now() - start);
113
+ }
114
+ };
115
+
116
+ /** Record a DB query duration against the current trace (no-op when untraced). */
117
+ export const traceDbQuery = (durationMs: number): void => {
118
+ getCurrentTrace()?.countDbQuery(durationMs);
119
+ };
120
+
121
+ /** Record a cache hit/miss against the current trace (no-op when untraced). */
122
+ export const traceCache = (hit: boolean): void => {
123
+ getCurrentTrace()?.countCache(hit);
124
+ };
125
+
126
+ /** Record a DataLoader batch against the current trace (no-op when untraced). */
127
+ export const traceDataLoaderBatch = (keyCount: number): void => {
128
+ getCurrentTrace()?.countDataLoaderBatch(keyCount);
129
+ };
130
+
131
+ interface SpanStat {
132
+ count: number;
133
+ sumMs: number;
134
+ maxMs: number;
135
+ samples: number[];
136
+ }
137
+
138
+ interface EndpointStat {
139
+ endpointKey: string;
140
+ endpointType: string;
141
+ requests: number;
142
+ spans: Map<string, SpanStat>;
143
+ dbQueryCount: number;
144
+ dbQueryMs: number;
145
+ cacheHits: number;
146
+ cacheMisses: number;
147
+ dataLoaderBatchCount: number;
148
+ dataLoaderKeyCount: number;
149
+ }
150
+
151
+ const RING_SIZE = 1024;
152
+
153
+ const percentile = (sorted: number[], p: number): number => {
154
+ if (sorted.length === 0) return 0;
155
+ const idx = Math.min(sorted.length - 1, Math.floor((p / 100) * sorted.length));
156
+ return sorted[idx] ?? 0;
157
+ };
158
+
159
+ /**
160
+ * Process-wide aggregator. Keeps rolling per-endpoint, per-span statistics with a
161
+ * bounded sample ring per span so percentiles stay representative of steady state
162
+ * without unbounded memory growth.
163
+ */
164
+ class TraceAggregator {
165
+ #endpoints = new Map<string, EndpointStat>();
166
+
167
+ ingest(trace: SignalTrace): void {
168
+ const id = `${trace.endpointType}:${trace.endpointKey}`;
169
+ let stat = this.#endpoints.get(id);
170
+ if (!stat) {
171
+ stat = {
172
+ endpointKey: trace.endpointKey,
173
+ endpointType: trace.endpointType,
174
+ requests: 0,
175
+ spans: new Map(),
176
+ dbQueryCount: 0,
177
+ dbQueryMs: 0,
178
+ cacheHits: 0,
179
+ cacheMisses: 0,
180
+ dataLoaderBatchCount: 0,
181
+ dataLoaderKeyCount: 0,
182
+ };
183
+ this.#endpoints.set(id, stat);
184
+ }
185
+ stat.requests += 1;
186
+ stat.dbQueryCount += trace.dbQueryCount;
187
+ stat.dbQueryMs += trace.dbQueryMs;
188
+ stat.cacheHits += trace.cacheHits;
189
+ stat.cacheMisses += trace.cacheMisses;
190
+ stat.dataLoaderBatchCount += trace.dataLoaderBatchCount;
191
+ stat.dataLoaderKeyCount += trace.dataLoaderKeyCount;
192
+ for (const span of trace.spans) {
193
+ let spanStat = stat.spans.get(span.name);
194
+ if (!spanStat) {
195
+ spanStat = { count: 0, sumMs: 0, maxMs: 0, samples: [] };
196
+ stat.spans.set(span.name, spanStat);
197
+ }
198
+ spanStat.count += 1;
199
+ spanStat.sumMs += span.durationMs;
200
+ spanStat.maxMs = Math.max(spanStat.maxMs, span.durationMs);
201
+ if (spanStat.samples.length < RING_SIZE) spanStat.samples.push(span.durationMs);
202
+ else spanStat.samples[spanStat.count % RING_SIZE] = span.durationMs;
203
+ }
204
+ }
205
+
206
+ reset(): void {
207
+ this.#endpoints.clear();
208
+ }
209
+
210
+ /** Summarized snapshot suitable for JSON exposure on the metrics endpoint. */
211
+ snapshot() {
212
+ const endpoints = [...this.#endpoints.values()].map((stat) => {
213
+ const spans = [...stat.spans.entries()].map(([name, s]) => {
214
+ const sorted = [...s.samples].sort((a, b) => a - b);
215
+ return {
216
+ name,
217
+ count: s.count,
218
+ meanMs: round(s.sumMs / s.count),
219
+ p50Ms: round(percentile(sorted, 50)),
220
+ p95Ms: round(percentile(sorted, 95)),
221
+ p99Ms: round(percentile(sorted, 99)),
222
+ maxMs: round(s.maxMs),
223
+ };
224
+ });
225
+ spans.sort((a, b) => b.meanMs - a.meanMs);
226
+ const cacheTotal = stat.cacheHits + stat.cacheMisses;
227
+ return {
228
+ endpoint: `${stat.endpointType}:${stat.endpointKey}`,
229
+ requests: stat.requests,
230
+ avgDbQueriesPerRequest: round(stat.dbQueryCount / stat.requests),
231
+ avgDbQueryMsPerRequest: round(stat.dbQueryMs / stat.requests),
232
+ cacheHitRatio: cacheTotal ? round(stat.cacheHits / cacheTotal, 4) : null,
233
+ avgDataLoaderBatchSize: stat.dataLoaderBatchCount
234
+ ? round(stat.dataLoaderKeyCount / stat.dataLoaderBatchCount)
235
+ : null,
236
+ spans,
237
+ };
238
+ });
239
+ endpoints.sort((a, b) => b.requests - a.requests);
240
+ return { enabled: isTraceEnabled(), endpoints };
241
+ }
242
+ }
243
+
244
+ const round = (value: number, digits = 3): number => {
245
+ const factor = 10 ** digits;
246
+ return Math.round(value * factor) / factor;
247
+ };
248
+
249
+ let aggregatorInstance = traceProcessStore.__akanTraceAggregator;
250
+ if (!aggregatorInstance) {
251
+ aggregatorInstance = new TraceAggregator();
252
+ traceProcessStore.__akanTraceAggregator = aggregatorInstance;
253
+ }
254
+ export const traceAggregator: TraceAggregator = aggregatorInstance;
255
+
256
+ /** Snapshot of all aggregated trace stats. Safe to call when tracing is disabled. */
257
+ export const getTraceSnapshot = () => traceAggregator.snapshot();
258
+
259
+ /**
260
+ * Optional file sink for the aggregated snapshot.
261
+ *
262
+ * The akan worker loads the app-server bundle and the framework metrics collector in
263
+ * separate module realms that share neither `globalThis` nor `process`, so the in-realm
264
+ * aggregator that records spans cannot be read by the metrics endpoint's collector. When
265
+ * `AKAN_TRACE_FILE` is set, the recording realm instead flushes the cumulative snapshot to
266
+ * that path (throttled), where any external reader (e.g. the benchmark harness) can pick it
267
+ * up. This is a benchmarking/diagnostics aid, not a production hot-path concern.
268
+ */
269
+ const traceFilePath = process.env.AKAN_TRACE_FILE;
270
+ let lastTraceFlushAt = 0;
271
+ const TRACE_FLUSH_INTERVAL_MS = 1_000;
272
+
273
+ const maybeFlushTraceFile = (): void => {
274
+ if (!traceFilePath) return;
275
+ const now = Date.now();
276
+ if (now - lastTraceFlushAt < TRACE_FLUSH_INTERVAL_MS) return;
277
+ lastTraceFlushAt = now;
278
+ void Bun.write(traceFilePath, JSON.stringify(traceAggregator.snapshot())).catch(() => {});
279
+ };
@@ -8,6 +8,8 @@ interface RunConfig {
8
8
  env: "local" | "debug" | "develop" | "main";
9
9
  regenerate?: boolean;
10
10
  }
11
+ interface PrepareConfig extends RunConfig {
12
+ }
11
13
  export declare class CapacitorApp {
12
14
  #private;
13
15
  private readonly app;
@@ -18,19 +20,26 @@ export declare class CapacitorApp {
18
20
  };
19
21
  iosTargetName: string;
20
22
  readonly targetRoot: string;
23
+ readonly targetRootPath: string;
24
+ readonly targetWebRoot: string;
25
+ readonly targetAssetRoot: string;
26
+ readonly iosRootPath = "ios";
27
+ readonly iosProjectPath = "ios/App";
28
+ readonly androidRootPath = "android";
21
29
  constructor(app: AppExecutor, target: AkanMobileTargetConfig);
22
- init({ platform, regenerate }?: {
30
+ init({ platform, operation, env, regenerate, }?: {
23
31
  platform?: "ios" | "android";
24
- regenerate?: boolean;
25
- }): Promise<this>;
32
+ } & Partial<PrepareConfig>): Promise<this>;
26
33
  save(): Promise<void>;
27
- buildIos(options?: {
34
+ buildIos({ env, regenerate }?: {
35
+ env?: RunConfig["env"];
28
36
  regenerate?: boolean;
29
37
  }): Promise<void>;
30
38
  syncIos(): Promise<void>;
31
39
  openIos(): Promise<void>;
32
40
  runIos({ operation, env, regenerate }: RunConfig): Promise<void>;
33
- buildAndroid(assembleType: "apk" | "aab", options?: {
41
+ buildAndroid(assembleType: "apk" | "aab", { env, regenerate }?: {
42
+ env?: RunConfig["env"];
34
43
  regenerate?: boolean;
35
44
  }): Promise<void>;
36
45
  openAndroid(): Promise<void>;
@@ -2,6 +2,8 @@ import type { AkanMetricsReport } from "akanjs/service";
2
2
  export declare class ProcessMetricsCollector {
3
3
  #private;
4
4
  static parseMemoryLogIntervalMs(value?: string | undefined): number;
5
+ /** Begin sampling event-loop lag. Idempotent; safe to call from each server role. */
6
+ static startEventLoopLagMonitor(intervalMs?: number): void;
5
7
  static collect(extra?: AkanMetricsReport): Promise<AkanMetricsReport>;
6
8
  static formatBytes(bytes?: number): string;
7
9
  static format(metrics: AkanMetricsReport): string;
@@ -108,6 +108,11 @@ export interface AkanMetricsReport {
108
108
  httpHtmlCacheHits?: number;
109
109
  httpHtmlCacheMisses?: number;
110
110
  httpHtmlCacheBypass?: number;
111
+ eventLoopLagMeanMs?: number;
112
+ eventLoopLagP99Ms?: number;
113
+ eventLoopLagMaxMs?: number;
114
+ gcDurationMs?: number;
115
+ trace?: unknown;
111
116
  }
112
117
  export type AkanIpcMessage = {
113
118
  type: "ready";