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
|
@@ -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 =
|
|
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 =
|
|
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({
|
|
91
|
-
|
|
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
|
|
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
package/signal/middleware.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/signal/signalContext.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
-
|
|
130
|
-
const
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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
|
|
315
|
-
|
|
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
|
});
|
package/signal/trace.ts
ADDED
|
@@ -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
|
-
|
|
25
|
-
}): Promise<this>;
|
|
32
|
+
} & Partial<PrepareConfig>): Promise<this>;
|
|
26
33
|
save(): Promise<void>;
|
|
27
|
-
buildIos(
|
|
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",
|
|
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";
|