silgi 0.0.14 → 0.1.0-beta.2
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.md +102 -1
- package/dist/_virtual/_rolldown/runtime.mjs +5 -0
- package/dist/adapters/astro.d.mts +17 -0
- package/dist/adapters/astro.mjs +24 -0
- package/dist/adapters/aws-lambda.d.mts +31 -0
- package/dist/adapters/aws-lambda.mjs +85 -0
- package/dist/adapters/elysia.d.mts +17 -0
- package/dist/adapters/elysia.mjs +76 -0
- package/dist/adapters/express.d.mts +16 -0
- package/dist/adapters/express.mjs +78 -0
- package/dist/adapters/fastify.d.mts +15 -0
- package/dist/adapters/fastify.mjs +78 -0
- package/dist/adapters/message-port.d.mts +37 -0
- package/dist/adapters/message-port.mjs +129 -0
- package/dist/adapters/nestjs.d.mts +25 -0
- package/dist/adapters/nestjs.mjs +91 -0
- package/dist/adapters/nextjs.d.mts +21 -0
- package/dist/adapters/nextjs.mjs +30 -0
- package/dist/adapters/peer.d.mts +27 -0
- package/dist/adapters/peer.mjs +36 -0
- package/dist/adapters/remix.d.mts +17 -0
- package/dist/adapters/remix.mjs +24 -0
- package/dist/adapters/solidstart.d.mts +14 -0
- package/dist/adapters/solidstart.mjs +30 -0
- package/dist/adapters/sveltekit.d.mts +18 -0
- package/dist/adapters/sveltekit.mjs +33 -0
- package/dist/analyze.mjs +26 -0
- package/dist/broker/index.d.mts +62 -0
- package/dist/broker/index.mjs +153 -0
- package/dist/broker/nats.d.mts +33 -0
- package/dist/broker/nats.mjs +31 -0
- package/dist/broker/redis.d.mts +51 -0
- package/dist/broker/redis.mjs +92 -0
- package/dist/builder.d.mts +36 -0
- package/dist/builder.mjs +51 -0
- package/dist/callable.d.mts +17 -0
- package/dist/callable.mjs +42 -0
- package/dist/client/adapters/fetch/index.d.mts +17 -0
- package/dist/client/adapters/fetch/index.mjs +61 -0
- package/dist/client/adapters/ofetch/index.d.mts +41 -0
- package/dist/client/adapters/ofetch/index.mjs +92 -0
- package/dist/client/client.d.mts +29 -0
- package/dist/client/client.mjs +54 -0
- package/dist/client/dynamic-link.d.mts +15 -0
- package/dist/client/dynamic-link.mjs +16 -0
- package/dist/client/index.d.mts +7 -0
- package/dist/client/index.mjs +6 -0
- package/dist/client/interceptor.d.mts +31 -0
- package/dist/client/interceptor.mjs +34 -0
- package/dist/client/merge.d.mts +28 -0
- package/dist/client/merge.mjs +30 -0
- package/dist/client/openapi.d.mts +29 -0
- package/dist/client/openapi.mjs +89 -0
- package/dist/client/plugins/batch.d.mts +20 -0
- package/dist/client/plugins/batch.mjs +64 -0
- package/dist/client/plugins/csrf.d.mts +13 -0
- package/dist/client/plugins/csrf.mjs +20 -0
- package/dist/client/plugins/dedupe.d.mts +10 -0
- package/dist/client/plugins/dedupe.mjs +28 -0
- package/dist/client/plugins/index.d.mts +5 -0
- package/dist/client/plugins/index.mjs +5 -0
- package/dist/client/plugins/retry.d.mts +11 -0
- package/dist/client/plugins/retry.mjs +21 -0
- package/dist/client/server.d.mts +16 -0
- package/dist/client/server.mjs +60 -0
- package/dist/client/types.d.mts +29 -0
- package/dist/codec/devalue.d.mts +21 -0
- package/dist/codec/devalue.mjs +32 -0
- package/dist/codec/msgpack.d.mts +21 -0
- package/dist/codec/msgpack.mjs +59 -0
- package/dist/compile.d.mts +54 -0
- package/dist/compile.mjs +305 -0
- package/dist/contract.d.mts +36 -0
- package/dist/contract.mjs +40 -0
- package/dist/core/error.d.mts +104 -0
- package/dist/core/error.mjs +139 -0
- package/dist/core/handler.mjs +546 -0
- package/dist/core/iterator.d.mts +17 -0
- package/dist/core/iterator.mjs +79 -0
- package/dist/core/router-utils.mjs +16 -0
- package/dist/core/schema.d.mts +19 -0
- package/dist/core/schema.mjs +26 -0
- package/dist/core/serve.mjs +38 -0
- package/dist/core/sse.d.mts +16 -0
- package/dist/core/sse.mjs +95 -0
- package/dist/core/storage.d.mts +21 -0
- package/dist/core/storage.mjs +63 -0
- package/dist/core/utils.mjs +21 -0
- package/dist/fast-stringify.mjs +125 -0
- package/dist/index.d.mts +15 -37
- package/dist/index.mjs +13 -7
- package/dist/integrations/ai/index.d.mts +25 -0
- package/dist/integrations/ai/index.mjs +116 -0
- package/dist/integrations/react/index.d.mts +83 -0
- package/dist/integrations/react/index.mjs +197 -0
- package/dist/integrations/tanstack-query/index.d.mts +120 -0
- package/dist/integrations/tanstack-query/index.mjs +100 -0
- package/dist/integrations/tanstack-query/ssr.d.mts +51 -0
- package/dist/integrations/tanstack-query/ssr.mjs +89 -0
- package/dist/integrations/zod/converter.d.mts +75 -0
- package/dist/integrations/zod/converter.mjs +345 -0
- package/dist/integrations/zod/index.d.mts +2 -0
- package/dist/integrations/zod/index.mjs +2 -0
- package/dist/lazy.d.mts +24 -0
- package/dist/lazy.mjs +27 -0
- package/dist/lifecycle.d.mts +36 -0
- package/dist/lifecycle.mjs +46 -0
- package/dist/map-input.d.mts +17 -0
- package/dist/map-input.mjs +24 -0
- package/dist/plugins/analytics.d.mts +168 -0
- package/dist/plugins/analytics.mjs +459 -0
- package/dist/plugins/batch-server.d.mts +20 -0
- package/dist/plugins/batch-server.mjs +86 -0
- package/dist/plugins/body-limit.d.mts +16 -0
- package/dist/plugins/body-limit.mjs +44 -0
- package/dist/plugins/cache.d.mts +170 -0
- package/dist/plugins/cache.mjs +200 -0
- package/dist/plugins/coerce.d.mts +21 -0
- package/dist/plugins/coerce.mjs +46 -0
- package/dist/plugins/compression.d.mts +19 -0
- package/dist/plugins/compression.mjs +23 -0
- package/dist/plugins/cookies.d.mts +44 -0
- package/dist/plugins/cookies.mjs +67 -0
- package/dist/plugins/cors.d.mts +39 -0
- package/dist/plugins/cors.mjs +56 -0
- package/dist/plugins/custom-serializer.d.mts +57 -0
- package/dist/plugins/custom-serializer.mjs +40 -0
- package/dist/plugins/file-upload.d.mts +38 -0
- package/dist/plugins/file-upload.mjs +100 -0
- package/dist/plugins/index.d.mts +16 -0
- package/dist/plugins/index.mjs +16 -0
- package/dist/plugins/otel.d.mts +35 -0
- package/dist/plugins/otel.mjs +40 -0
- package/dist/plugins/pino.d.mts +60 -0
- package/dist/plugins/pino.mjs +42 -0
- package/dist/plugins/pubsub.d.mts +50 -0
- package/dist/plugins/pubsub.mjs +53 -0
- package/dist/plugins/ratelimit.d.mts +51 -0
- package/dist/plugins/ratelimit.mjs +81 -0
- package/dist/plugins/signing.d.mts +41 -0
- package/dist/plugins/signing.mjs +115 -0
- package/dist/plugins/strict-get.d.mts +10 -0
- package/dist/plugins/strict-get.mjs +33 -0
- package/dist/route/add.mjs +240 -0
- package/dist/route/compiler.mjs +373 -0
- package/dist/route/context.mjs +12 -0
- package/dist/route/types.d.mts +11 -0
- package/dist/route/utils.mjs +17 -0
- package/dist/scalar.d.mts +53 -0
- package/dist/scalar.mjs +330 -0
- package/dist/silgi.d.mts +139 -0
- package/dist/silgi.mjs +113 -0
- package/dist/trpc-interop.d.mts +22 -0
- package/dist/trpc-interop.mjs +68 -0
- package/dist/types.d.mts +82 -0
- package/dist/ws.d.mts +42 -0
- package/dist/ws.mjs +137 -0
- package/lib/dashboard/index.html +123 -0
- package/lib/ocache.d.mts +1 -0
- package/lib/ocache.mjs +1 -0
- package/lib/ofetch.d.mts +1 -0
- package/lib/ofetch.mjs +1 -0
- package/lib/srvx.d.mts +1 -0
- package/lib/srvx.mjs +1 -0
- package/lib/unstorage.d.mts +1 -0
- package/lib/unstorage.mjs +1 -0
- package/package.json +291 -65
- package/bin/silgi.mjs +0 -3
- package/dist/chunks/generate.mjs +0 -933
- package/dist/chunks/init.mjs +0 -21
- package/dist/cli/config.d.mts +0 -19
- package/dist/cli/config.d.ts +0 -19
- package/dist/cli/config.mjs +0 -5
- package/dist/cli/index.d.mts +0 -2
- package/dist/cli/index.d.ts +0 -2
- package/dist/cli/index.mjs +0 -119
- package/dist/index.d.ts +0 -37
- package/dist/plugins/openapi.d.mts +0 -138
- package/dist/plugins/openapi.d.ts +0 -138
- package/dist/plugins/openapi.mjs +0 -204
- package/dist/plugins/scalar.d.mts +0 -14
- package/dist/plugins/scalar.d.ts +0 -14
- package/dist/plugins/scalar.mjs +0 -66
- package/dist/shared/silgi.BMCYk2cR.mjs +0 -841
- package/dist/shared/silgi.D5qK9QOm.d.mts +0 -301
- package/dist/shared/silgi.D5qK9QOm.d.ts +0 -301
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
//#region src/plugins/analytics.d.ts
|
|
2
|
+
/**
|
|
3
|
+
* Built-in analytics plugin — zero-dependency monitoring with deep error tracing.
|
|
4
|
+
*
|
|
5
|
+
* - Per-procedure metrics (count, errors, latency percentiles) via ring buffers
|
|
6
|
+
* - Full error log with input, headers, stack trace, custom spans
|
|
7
|
+
* - `trace()` helper for measuring DB queries, API calls, etc.
|
|
8
|
+
* - "Copy for AI" — one-click markdown export of any error
|
|
9
|
+
* - HTTP-level request tracking with procedure grouping (batch support)
|
|
10
|
+
* - Unique request IDs via `x-request-id` response header
|
|
11
|
+
*
|
|
12
|
+
* Dashboard at /analytics, JSON API at /analytics/api, errors at /analytics/errors.
|
|
13
|
+
*/
|
|
14
|
+
interface TimeWindow {
|
|
15
|
+
time: number;
|
|
16
|
+
count: number;
|
|
17
|
+
errors: number;
|
|
18
|
+
}
|
|
19
|
+
type SpanKind = 'db' | 'http' | 'cache' | 'queue' | 'email' | 'ai' | 'custom';
|
|
20
|
+
interface TraceSpan {
|
|
21
|
+
name: string;
|
|
22
|
+
kind: SpanKind;
|
|
23
|
+
durationMs: number;
|
|
24
|
+
startOffsetMs?: number;
|
|
25
|
+
detail?: string;
|
|
26
|
+
error?: string;
|
|
27
|
+
}
|
|
28
|
+
interface ErrorEntry {
|
|
29
|
+
id: number;
|
|
30
|
+
/** Links back to the RequestEntry that produced this error. */
|
|
31
|
+
requestId: string;
|
|
32
|
+
timestamp: number;
|
|
33
|
+
procedure: string;
|
|
34
|
+
error: string;
|
|
35
|
+
code: string;
|
|
36
|
+
status: number;
|
|
37
|
+
stack: string;
|
|
38
|
+
input: unknown;
|
|
39
|
+
headers: Record<string, string>;
|
|
40
|
+
durationMs: number;
|
|
41
|
+
spans: TraceSpan[];
|
|
42
|
+
}
|
|
43
|
+
/** A single procedure call within an HTTP request. */
|
|
44
|
+
interface ProcedureCall {
|
|
45
|
+
procedure: string;
|
|
46
|
+
durationMs: number;
|
|
47
|
+
status: number;
|
|
48
|
+
input: unknown;
|
|
49
|
+
output: unknown;
|
|
50
|
+
spans: TraceSpan[];
|
|
51
|
+
error?: string;
|
|
52
|
+
}
|
|
53
|
+
/** An HTTP request containing one or more procedure calls. */
|
|
54
|
+
interface RequestEntry {
|
|
55
|
+
id: number;
|
|
56
|
+
/** Unique request ID (returned in x-request-id response header). */
|
|
57
|
+
requestId: string;
|
|
58
|
+
/** Persistent session ID (from cookie — survives browser restart). */
|
|
59
|
+
sessionId: string;
|
|
60
|
+
timestamp: number;
|
|
61
|
+
durationMs: number;
|
|
62
|
+
method: string;
|
|
63
|
+
path: string;
|
|
64
|
+
ip: string;
|
|
65
|
+
headers: Record<string, string>;
|
|
66
|
+
responseHeaders: Record<string, string>;
|
|
67
|
+
userAgent: string;
|
|
68
|
+
status: number;
|
|
69
|
+
procedures: ProcedureCall[];
|
|
70
|
+
isBatch: boolean;
|
|
71
|
+
}
|
|
72
|
+
interface AnalyticsOptions {
|
|
73
|
+
/** Latency samples to keep per procedure (default: 1024) */
|
|
74
|
+
bufferSize?: number;
|
|
75
|
+
/** Time-series history in seconds (default: 120) */
|
|
76
|
+
historySeconds?: number;
|
|
77
|
+
/** Max error entries to keep (default: 100) */
|
|
78
|
+
maxErrors?: number;
|
|
79
|
+
/** Max recent request entries to keep (default: 200) */
|
|
80
|
+
maxRequests?: number;
|
|
81
|
+
/**
|
|
82
|
+
* Protect dashboard access.
|
|
83
|
+
* - `string` — secret token checked against `Authorization: Bearer <token>` header or `?token=` query param
|
|
84
|
+
* - `(req: Request) => boolean | Promise<boolean>` — custom auth function
|
|
85
|
+
* - `undefined` — no auth (open access, NOT recommended in production)
|
|
86
|
+
*/
|
|
87
|
+
auth?: string | ((req: Request) => boolean | Promise<boolean>);
|
|
88
|
+
}
|
|
89
|
+
interface ProcedureSnapshot {
|
|
90
|
+
count: number;
|
|
91
|
+
errors: number;
|
|
92
|
+
errorRate: number;
|
|
93
|
+
latency: {
|
|
94
|
+
avg: number;
|
|
95
|
+
p50: number;
|
|
96
|
+
p95: number;
|
|
97
|
+
p99: number;
|
|
98
|
+
};
|
|
99
|
+
lastError: string | null;
|
|
100
|
+
lastErrorTime: number | null;
|
|
101
|
+
}
|
|
102
|
+
interface AnalyticsSnapshot {
|
|
103
|
+
uptime: number;
|
|
104
|
+
totalRequests: number;
|
|
105
|
+
totalErrors: number;
|
|
106
|
+
errorRate: number;
|
|
107
|
+
requestsPerSecond: number;
|
|
108
|
+
avgLatency: number;
|
|
109
|
+
procedures: Record<string, ProcedureSnapshot>;
|
|
110
|
+
timeSeries: TimeWindow[];
|
|
111
|
+
}
|
|
112
|
+
declare class RequestTrace {
|
|
113
|
+
#private;
|
|
114
|
+
spans: TraceSpan[];
|
|
115
|
+
trace<T>(name: string, fn: () => T | Promise<T>, opts?: {
|
|
116
|
+
kind?: SpanKind;
|
|
117
|
+
detail?: string;
|
|
118
|
+
}): Promise<T>;
|
|
119
|
+
totalByKind(kind: SpanKind): number;
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Standalone trace function — works with or without analytics.
|
|
123
|
+
*
|
|
124
|
+
* ```ts
|
|
125
|
+
* import { trace } from 'silgi/analytics'
|
|
126
|
+
*
|
|
127
|
+
* const listUsers = s.$resolve(async ({ ctx }) => {
|
|
128
|
+
* return await trace(ctx, 'db.users.findMany', () => db.users.findMany())
|
|
129
|
+
* // or with explicit kind:
|
|
130
|
+
* return await trace(ctx, 'findUsers', () => db.users.findMany(), { kind: 'db', detail: 'SELECT * FROM users' })
|
|
131
|
+
* })
|
|
132
|
+
* ```
|
|
133
|
+
*/
|
|
134
|
+
declare function trace<T>(ctx: Record<string, unknown>, name: string, fn: () => T | Promise<T>, opts?: {
|
|
135
|
+
kind?: SpanKind;
|
|
136
|
+
detail?: string;
|
|
137
|
+
}): Promise<T>;
|
|
138
|
+
declare class AnalyticsCollector {
|
|
139
|
+
#private;
|
|
140
|
+
constructor(options?: AnalyticsOptions);
|
|
141
|
+
record(path: string, durationMs: number): void;
|
|
142
|
+
recordError(path: string, durationMs: number, errorMsg: string): void;
|
|
143
|
+
recordDetailedError(entry: Omit<ErrorEntry, 'id'>): void;
|
|
144
|
+
recordDetailedRequest(entry: Omit<RequestEntry, 'id'>): void;
|
|
145
|
+
getErrors(): ErrorEntry[];
|
|
146
|
+
getRequests(): RequestEntry[];
|
|
147
|
+
toJSON(): AnalyticsSnapshot;
|
|
148
|
+
}
|
|
149
|
+
declare class RequestAccumulator {
|
|
150
|
+
#private;
|
|
151
|
+
readonly requestId: string;
|
|
152
|
+
readonly sessionId: string;
|
|
153
|
+
/** True if a new session cookie needs to be set. */
|
|
154
|
+
readonly isNewSession: boolean;
|
|
155
|
+
constructor(request: Request, collector: AnalyticsCollector);
|
|
156
|
+
addProcedure(call: ProcedureCall): void;
|
|
157
|
+
/** Get Set-Cookie header value (only if new session). */
|
|
158
|
+
getSessionCookie(): string | null;
|
|
159
|
+
/** Flush with response headers extracted from the actual Response object. */
|
|
160
|
+
flushWithResponse(res: Response): void;
|
|
161
|
+
/** Whether any procedures have been recorded. */
|
|
162
|
+
get hasProcedures(): boolean;
|
|
163
|
+
}
|
|
164
|
+
declare function errorToMarkdown(e: ErrorEntry): string;
|
|
165
|
+
declare function requestToMarkdown(r: RequestEntry): string;
|
|
166
|
+
declare function analyticsHTML(): string;
|
|
167
|
+
//#endregion
|
|
168
|
+
export { AnalyticsCollector, AnalyticsOptions, AnalyticsSnapshot, ErrorEntry, ProcedureCall, ProcedureSnapshot, RequestAccumulator, RequestEntry, RequestTrace, SpanKind, TraceSpan, analyticsHTML, errorToMarkdown, requestToMarkdown, trace };
|
|
@@ -0,0 +1,459 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
//#region src/plugins/analytics.ts
|
|
5
|
+
/**
|
|
6
|
+
* Built-in analytics plugin — zero-dependency monitoring with deep error tracing.
|
|
7
|
+
*
|
|
8
|
+
* - Per-procedure metrics (count, errors, latency percentiles) via ring buffers
|
|
9
|
+
* - Full error log with input, headers, stack trace, custom spans
|
|
10
|
+
* - `trace()` helper for measuring DB queries, API calls, etc.
|
|
11
|
+
* - "Copy for AI" — one-click markdown export of any error
|
|
12
|
+
* - HTTP-level request tracking with procedure grouping (batch support)
|
|
13
|
+
* - Unique request IDs via `x-request-id` response header
|
|
14
|
+
*
|
|
15
|
+
* Dashboard at /analytics, JSON API at /analytics/api, errors at /analytics/errors.
|
|
16
|
+
*/
|
|
17
|
+
var RingBuffer = class {
|
|
18
|
+
#data;
|
|
19
|
+
#size;
|
|
20
|
+
#head = 0;
|
|
21
|
+
#count = 0;
|
|
22
|
+
constructor(size) {
|
|
23
|
+
this.#data = new Float64Array(size);
|
|
24
|
+
this.#size = size;
|
|
25
|
+
}
|
|
26
|
+
push(value) {
|
|
27
|
+
this.#data[this.#head] = value;
|
|
28
|
+
this.#head = (this.#head + 1) % this.#size;
|
|
29
|
+
if (this.#count < this.#size) this.#count++;
|
|
30
|
+
}
|
|
31
|
+
percentile(p) {
|
|
32
|
+
if (this.#count === 0) return 0;
|
|
33
|
+
const arr = new Float64Array(this.#count);
|
|
34
|
+
if (this.#count < this.#size) arr.set(this.#data.subarray(0, this.#count));
|
|
35
|
+
else arr.set(this.#data);
|
|
36
|
+
arr.sort();
|
|
37
|
+
const idx = Math.ceil(p / 100 * this.#count) - 1;
|
|
38
|
+
return arr[Math.max(0, idx)];
|
|
39
|
+
}
|
|
40
|
+
avg() {
|
|
41
|
+
if (this.#count === 0) return 0;
|
|
42
|
+
let sum = 0;
|
|
43
|
+
const n = this.#count;
|
|
44
|
+
for (let i = 0; i < n; i++) sum += this.#data[i];
|
|
45
|
+
return sum / n;
|
|
46
|
+
}
|
|
47
|
+
get count() {
|
|
48
|
+
return this.#count;
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
let _lastTime = 0;
|
|
52
|
+
let _counter = 0;
|
|
53
|
+
function generateRequestId() {
|
|
54
|
+
let now = Date.now();
|
|
55
|
+
if (now === _lastTime) {
|
|
56
|
+
_counter = _counter + 1 & 4095;
|
|
57
|
+
if (_counter === 0) while (now === _lastTime) now = Date.now();
|
|
58
|
+
} else {
|
|
59
|
+
_counter = 0;
|
|
60
|
+
_lastTime = now;
|
|
61
|
+
}
|
|
62
|
+
const high = Math.floor(now / 1024);
|
|
63
|
+
const low = (now & 1023) << 22 | _counter << 10 | Math.random() * 1024 >>> 0;
|
|
64
|
+
return high.toString(36) + low.toString(36).padStart(7, "0");
|
|
65
|
+
}
|
|
66
|
+
var RequestTrace = class {
|
|
67
|
+
spans = [];
|
|
68
|
+
#t0 = performance.now();
|
|
69
|
+
async trace(name, fn, opts) {
|
|
70
|
+
const start = performance.now();
|
|
71
|
+
const kind = opts?.kind ?? guessKind(name);
|
|
72
|
+
try {
|
|
73
|
+
const result = await fn();
|
|
74
|
+
this.spans.push({
|
|
75
|
+
name,
|
|
76
|
+
kind,
|
|
77
|
+
durationMs: round(performance.now() - start),
|
|
78
|
+
startOffsetMs: round(start - this.#t0),
|
|
79
|
+
detail: opts?.detail
|
|
80
|
+
});
|
|
81
|
+
return result;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
this.spans.push({
|
|
84
|
+
name,
|
|
85
|
+
kind,
|
|
86
|
+
durationMs: round(performance.now() - start),
|
|
87
|
+
startOffsetMs: round(start - this.#t0),
|
|
88
|
+
detail: opts?.detail,
|
|
89
|
+
error: err instanceof Error ? err.message : String(err)
|
|
90
|
+
});
|
|
91
|
+
throw err;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
totalByKind(kind) {
|
|
95
|
+
let total = 0;
|
|
96
|
+
for (const s of this.spans) if (s.kind === kind) total += s.durationMs;
|
|
97
|
+
return round(total);
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
function guessKind(name) {
|
|
101
|
+
const lower = name.toLowerCase();
|
|
102
|
+
if (lower.startsWith("db.") || lower.includes("sql") || lower.includes("prisma") || lower.includes("drizzle") || lower.includes("query") || lower.includes("mongo")) return "db";
|
|
103
|
+
if (lower.startsWith("http.") || lower.includes("fetch") || lower.includes("api.")) return "http";
|
|
104
|
+
if (lower.startsWith("cache.") || lower.includes("redis") || lower.includes("memcache")) return "cache";
|
|
105
|
+
if (lower.includes("queue") || lower.includes("publish") || lower.includes("nats") || lower.includes("kafka")) return "queue";
|
|
106
|
+
if (lower.includes("email") || lower.includes("smtp") || lower.includes("ses")) return "email";
|
|
107
|
+
if (lower.includes("ai") || lower.includes("llm") || lower.includes("openai") || lower.includes("gemini")) return "ai";
|
|
108
|
+
return "custom";
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Standalone trace function — works with or without analytics.
|
|
112
|
+
*
|
|
113
|
+
* ```ts
|
|
114
|
+
* import { trace } from 'silgi/analytics'
|
|
115
|
+
*
|
|
116
|
+
* const listUsers = s.$resolve(async ({ ctx }) => {
|
|
117
|
+
* return await trace(ctx, 'db.users.findMany', () => db.users.findMany())
|
|
118
|
+
* // or with explicit kind:
|
|
119
|
+
* return await trace(ctx, 'findUsers', () => db.users.findMany(), { kind: 'db', detail: 'SELECT * FROM users' })
|
|
120
|
+
* })
|
|
121
|
+
* ```
|
|
122
|
+
*/
|
|
123
|
+
async function trace(ctx, name, fn, opts) {
|
|
124
|
+
const reqTrace = ctx.__analyticsTrace;
|
|
125
|
+
if (reqTrace) return reqTrace.trace(name, fn, opts);
|
|
126
|
+
return fn();
|
|
127
|
+
}
|
|
128
|
+
var AnalyticsCollector = class {
|
|
129
|
+
#procedures = /* @__PURE__ */ new Map();
|
|
130
|
+
#startTime = Date.now();
|
|
131
|
+
#totalRequests = 0;
|
|
132
|
+
#totalErrors = 0;
|
|
133
|
+
#bufferSize;
|
|
134
|
+
#historySeconds;
|
|
135
|
+
#maxErrors;
|
|
136
|
+
#maxRequests;
|
|
137
|
+
#timeSeries = [];
|
|
138
|
+
#currentWindow;
|
|
139
|
+
#errors = [];
|
|
140
|
+
#nextErrorId = 1;
|
|
141
|
+
#requests = [];
|
|
142
|
+
#nextRequestId = 1;
|
|
143
|
+
constructor(options = {}) {
|
|
144
|
+
this.#bufferSize = options.bufferSize ?? 1024;
|
|
145
|
+
this.#historySeconds = options.historySeconds ?? 120;
|
|
146
|
+
this.#maxErrors = options.maxErrors ?? 100;
|
|
147
|
+
this.#maxRequests = options.maxRequests ?? 200;
|
|
148
|
+
this.#currentWindow = {
|
|
149
|
+
time: Math.floor(Date.now() / 1e3),
|
|
150
|
+
count: 0,
|
|
151
|
+
errors: 0
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
record(path, durationMs) {
|
|
155
|
+
this.#totalRequests++;
|
|
156
|
+
const entry = this.#getOrCreate(path);
|
|
157
|
+
entry.count++;
|
|
158
|
+
entry.latencies.push(durationMs);
|
|
159
|
+
this.#tick(false);
|
|
160
|
+
}
|
|
161
|
+
recordError(path, durationMs, errorMsg) {
|
|
162
|
+
this.#totalRequests++;
|
|
163
|
+
this.#totalErrors++;
|
|
164
|
+
const entry = this.#getOrCreate(path);
|
|
165
|
+
entry.count++;
|
|
166
|
+
entry.errors++;
|
|
167
|
+
entry.latencies.push(durationMs);
|
|
168
|
+
entry.lastError = errorMsg;
|
|
169
|
+
entry.lastErrorTime = Date.now();
|
|
170
|
+
this.#tick(true);
|
|
171
|
+
}
|
|
172
|
+
recordDetailedError(entry) {
|
|
173
|
+
this.#errors.push({
|
|
174
|
+
...entry,
|
|
175
|
+
id: this.#nextErrorId++
|
|
176
|
+
});
|
|
177
|
+
if (this.#errors.length > this.#maxErrors) this.#errors.shift();
|
|
178
|
+
}
|
|
179
|
+
recordDetailedRequest(entry) {
|
|
180
|
+
this.#requests.push({
|
|
181
|
+
...entry,
|
|
182
|
+
id: this.#nextRequestId++
|
|
183
|
+
});
|
|
184
|
+
if (this.#requests.length > this.#maxRequests) this.#requests.shift();
|
|
185
|
+
}
|
|
186
|
+
#getOrCreate(path) {
|
|
187
|
+
let entry = this.#procedures.get(path);
|
|
188
|
+
if (!entry) {
|
|
189
|
+
entry = {
|
|
190
|
+
count: 0,
|
|
191
|
+
errors: 0,
|
|
192
|
+
latencies: new RingBuffer(this.#bufferSize),
|
|
193
|
+
lastError: null,
|
|
194
|
+
lastErrorTime: 0
|
|
195
|
+
};
|
|
196
|
+
this.#procedures.set(path, entry);
|
|
197
|
+
}
|
|
198
|
+
return entry;
|
|
199
|
+
}
|
|
200
|
+
#tick(isError) {
|
|
201
|
+
const now = Math.floor(Date.now() / 1e3);
|
|
202
|
+
if (now !== this.#currentWindow.time) {
|
|
203
|
+
if (this.#currentWindow.count > 0) {
|
|
204
|
+
this.#timeSeries.push({ ...this.#currentWindow });
|
|
205
|
+
if (this.#timeSeries.length > this.#historySeconds) this.#timeSeries.shift();
|
|
206
|
+
}
|
|
207
|
+
this.#currentWindow = {
|
|
208
|
+
time: now,
|
|
209
|
+
count: 0,
|
|
210
|
+
errors: 0
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
this.#currentWindow.count++;
|
|
214
|
+
if (isError) this.#currentWindow.errors++;
|
|
215
|
+
}
|
|
216
|
+
getErrors() {
|
|
217
|
+
return this.#errors;
|
|
218
|
+
}
|
|
219
|
+
getRequests() {
|
|
220
|
+
return this.#requests;
|
|
221
|
+
}
|
|
222
|
+
toJSON() {
|
|
223
|
+
const uptimeSeconds = (Date.now() - this.#startTime) / 1e3;
|
|
224
|
+
const procedures = {};
|
|
225
|
+
let totalLatencySum = 0;
|
|
226
|
+
let totalLatencyCount = 0;
|
|
227
|
+
for (const [path, entry] of this.#procedures) {
|
|
228
|
+
const avg = entry.latencies.avg();
|
|
229
|
+
procedures[path] = {
|
|
230
|
+
count: entry.count,
|
|
231
|
+
errors: entry.errors,
|
|
232
|
+
errorRate: entry.count > 0 ? round(entry.errors / entry.count * 100) : 0,
|
|
233
|
+
latency: {
|
|
234
|
+
avg: round(avg),
|
|
235
|
+
p50: round(entry.latencies.percentile(50)),
|
|
236
|
+
p95: round(entry.latencies.percentile(95)),
|
|
237
|
+
p99: round(entry.latencies.percentile(99))
|
|
238
|
+
},
|
|
239
|
+
lastError: entry.lastError,
|
|
240
|
+
lastErrorTime: entry.lastErrorTime || null
|
|
241
|
+
};
|
|
242
|
+
totalLatencySum += avg * entry.latencies.count;
|
|
243
|
+
totalLatencyCount += entry.latencies.count;
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
uptime: Math.round(uptimeSeconds),
|
|
247
|
+
totalRequests: this.#totalRequests,
|
|
248
|
+
totalErrors: this.#totalErrors,
|
|
249
|
+
errorRate: this.#totalRequests > 0 ? round(this.#totalErrors / this.#totalRequests * 100) : 0,
|
|
250
|
+
requestsPerSecond: uptimeSeconds > 0 ? round(this.#totalRequests / uptimeSeconds) : 0,
|
|
251
|
+
avgLatency: totalLatencyCount > 0 ? round(totalLatencySum / totalLatencyCount) : 0,
|
|
252
|
+
procedures,
|
|
253
|
+
timeSeries: this.#currentWindow.count > 0 ? [...this.#timeSeries, this.#currentWindow] : [...this.#timeSeries]
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
};
|
|
257
|
+
function round(n) {
|
|
258
|
+
return Math.round(n * 100) / 100;
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Collects procedure calls within a single HTTP request.
|
|
262
|
+
* Created at the start of handleRequest, procedures are added as they complete,
|
|
263
|
+
* then flushed to the collector at the end.
|
|
264
|
+
*
|
|
265
|
+
* Sets `x-request-id` response header automatically.
|
|
266
|
+
*/
|
|
267
|
+
const SESSION_COOKIE = "_sid";
|
|
268
|
+
const SESSION_MAX_AGE = 365 * 24 * 60 * 60;
|
|
269
|
+
var RequestAccumulator = class {
|
|
270
|
+
requestId;
|
|
271
|
+
sessionId;
|
|
272
|
+
/** True if a new session cookie needs to be set. */
|
|
273
|
+
isNewSession;
|
|
274
|
+
#t0;
|
|
275
|
+
#request;
|
|
276
|
+
#procedures = [];
|
|
277
|
+
#collector;
|
|
278
|
+
constructor(request, collector) {
|
|
279
|
+
this.requestId = generateRequestId();
|
|
280
|
+
this.#t0 = performance.now();
|
|
281
|
+
this.#request = request;
|
|
282
|
+
this.#collector = collector;
|
|
283
|
+
const existing = parseCookie(request.headers.get("cookie"), SESSION_COOKIE);
|
|
284
|
+
if (existing && existing.length >= 10) {
|
|
285
|
+
this.sessionId = existing;
|
|
286
|
+
this.isNewSession = false;
|
|
287
|
+
} else {
|
|
288
|
+
this.sessionId = generateRequestId();
|
|
289
|
+
this.isNewSession = true;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
addProcedure(call) {
|
|
293
|
+
this.#procedures.push(call);
|
|
294
|
+
}
|
|
295
|
+
/** Get Set-Cookie header value (only if new session). */
|
|
296
|
+
getSessionCookie() {
|
|
297
|
+
if (!this.isNewSession) return null;
|
|
298
|
+
return `${SESSION_COOKIE}=${this.sessionId}; Path=/; Max-Age=${SESSION_MAX_AGE}; SameSite=Lax; HttpOnly`;
|
|
299
|
+
}
|
|
300
|
+
/** Flush with response headers extracted from the actual Response object. */
|
|
301
|
+
flushWithResponse(res) {
|
|
302
|
+
if (this.#procedures.length === 0) return;
|
|
303
|
+
const durationMs = round(performance.now() - this.#t0);
|
|
304
|
+
const headers = {};
|
|
305
|
+
this.#request.headers.forEach((v, k) => {
|
|
306
|
+
headers[k] = k === "authorization" || k === "cookie" ? "[REDACTED]" : v;
|
|
307
|
+
});
|
|
308
|
+
const responseHeaders = {};
|
|
309
|
+
res.headers.forEach((v, k) => {
|
|
310
|
+
responseHeaders[k] = k === "set-cookie" ? "[REDACTED]" : v;
|
|
311
|
+
});
|
|
312
|
+
let worstStatus = 200;
|
|
313
|
+
for (const p of this.#procedures) if (p.status > worstStatus) worstStatus = p.status;
|
|
314
|
+
const url = this.#request.url;
|
|
315
|
+
const pathStart = url.indexOf("/", url.indexOf("//") + 2);
|
|
316
|
+
const qMark = url.indexOf("?", pathStart);
|
|
317
|
+
const path = qMark === -1 ? url.slice(pathStart) : url.slice(pathStart, qMark);
|
|
318
|
+
this.#collector.recordDetailedRequest({
|
|
319
|
+
requestId: this.requestId,
|
|
320
|
+
sessionId: this.sessionId,
|
|
321
|
+
timestamp: Date.now(),
|
|
322
|
+
durationMs,
|
|
323
|
+
method: this.#request.method,
|
|
324
|
+
path,
|
|
325
|
+
ip: headers["x-forwarded-for"] || headers["x-real-ip"] || "",
|
|
326
|
+
headers,
|
|
327
|
+
responseHeaders,
|
|
328
|
+
userAgent: this.#request.headers.get("user-agent") ?? "",
|
|
329
|
+
status: worstStatus,
|
|
330
|
+
procedures: this.#procedures,
|
|
331
|
+
isBatch: this.#procedures.length > 1
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
/** Whether any procedures have been recorded. */
|
|
335
|
+
get hasProcedures() {
|
|
336
|
+
return this.#procedures.length > 0;
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
function parseCookie(header, name) {
|
|
340
|
+
if (!header) return void 0;
|
|
341
|
+
const prefix = name + "=";
|
|
342
|
+
let start = header.indexOf(prefix);
|
|
343
|
+
if (start === -1) return void 0;
|
|
344
|
+
if (start > 0 && header[start - 1] !== " " && header[start - 1] !== ";") {
|
|
345
|
+
start = header.indexOf("; " + prefix);
|
|
346
|
+
if (start === -1) return void 0;
|
|
347
|
+
start += 2;
|
|
348
|
+
}
|
|
349
|
+
const valueStart = start + prefix.length;
|
|
350
|
+
const valueEnd = header.indexOf(";", valueStart);
|
|
351
|
+
return valueEnd === -1 ? header.slice(valueStart) : header.slice(valueStart, valueEnd);
|
|
352
|
+
}
|
|
353
|
+
function errorToMarkdown(e) {
|
|
354
|
+
const time = new Date(e.timestamp).toISOString();
|
|
355
|
+
const inputJson = safeStringify(e.input);
|
|
356
|
+
let md = `## Error in \`${e.procedure}\`\n\n`;
|
|
357
|
+
md += `**Time:** ${time} \n`;
|
|
358
|
+
md += `**Error:** ${e.code} \n`;
|
|
359
|
+
md += `**Status:** ${e.status} \n`;
|
|
360
|
+
md += `**Duration:** ${e.durationMs}ms\n\n`;
|
|
361
|
+
if (e.input !== void 0) md += `### Input\n\n\`\`\`json\n${inputJson}\n\`\`\`\n\n`;
|
|
362
|
+
if (e.stack) md += `### Stack Trace\n\n\`\`\`\n${e.stack}\n\`\`\`\n\n`;
|
|
363
|
+
if (Object.keys(e.headers).length > 0) {
|
|
364
|
+
md += `### Request Headers\n\n`;
|
|
365
|
+
for (const [k, v] of Object.entries(e.headers)) if (k === "authorization") md += `- \`${k}\`: \`[REDACTED]\`\n`;
|
|
366
|
+
else md += `- \`${k}\`: \`${v}\`\n`;
|
|
367
|
+
md += "\n";
|
|
368
|
+
}
|
|
369
|
+
if (e.spans.length > 0) {
|
|
370
|
+
md += `### Traced Operations\n\n`;
|
|
371
|
+
for (let i = 0; i < e.spans.length; i++) {
|
|
372
|
+
const s = e.spans[i];
|
|
373
|
+
const errMark = s.error ? ` ❌ ${s.error}` : "";
|
|
374
|
+
md += `**${i + 1}. [${s.kind}] ${s.name}** — ${s.durationMs}ms${errMark}\n`;
|
|
375
|
+
if (s.detail) md += `\`\`\`\n${s.detail}\n\`\`\`\n`;
|
|
376
|
+
}
|
|
377
|
+
md += "\n";
|
|
378
|
+
}
|
|
379
|
+
md += `### Error Message\n\n\`\`\`\n${e.error}\n\`\`\``;
|
|
380
|
+
return md;
|
|
381
|
+
}
|
|
382
|
+
function requestToMarkdown(r) {
|
|
383
|
+
const time = new Date(r.timestamp).toISOString();
|
|
384
|
+
let md = `## ${r.status >= 500 ? "💥" : r.status >= 400 ? "⚠️" : "✅"} ${r.method} ${r.path} → ${r.status} (${r.durationMs}ms)\n\n`;
|
|
385
|
+
md += `| Field | Value |\n|-------|-------|\n`;
|
|
386
|
+
md += `| Request ID | \`${r.requestId}\` |\n`;
|
|
387
|
+
md += `| Session ID | \`${r.sessionId}\` |\n`;
|
|
388
|
+
md += `| Method | ${r.method} |\n`;
|
|
389
|
+
md += `| Path | \`${r.path}\` |\n`;
|
|
390
|
+
md += `| Status | ${r.status} |\n`;
|
|
391
|
+
md += `| Duration | ${r.durationMs}ms |\n`;
|
|
392
|
+
md += `| Time | ${time} |\n`;
|
|
393
|
+
md += `| IP | ${r.ip} |\n`;
|
|
394
|
+
md += `| Procedures | ${r.procedures.length} |\n`;
|
|
395
|
+
if (r.isBatch) md += `| Batch | Yes |\n`;
|
|
396
|
+
md += "\n";
|
|
397
|
+
for (let i = 0; i < r.procedures.length; i++) {
|
|
398
|
+
const p = r.procedures[i];
|
|
399
|
+
const pEmoji = p.status >= 400 ? "⚠️" : "✅";
|
|
400
|
+
md += `### ${pEmoji} ${i + 1}. \`${p.procedure}\` → ${p.status} (${p.durationMs}ms)\n\n`;
|
|
401
|
+
if (p.input !== void 0 && p.input !== null) md += `#### Input\n\n\`\`\`json\n${safeStringify(p.input)}\n\`\`\`\n\n`;
|
|
402
|
+
if (p.spans.length > 0) {
|
|
403
|
+
const byKind = /* @__PURE__ */ new Map();
|
|
404
|
+
for (const s of p.spans) byKind.set(s.kind, (byKind.get(s.kind) ?? 0) + s.durationMs);
|
|
405
|
+
const tracedMs = [...byKind.values()].reduce((a, b) => a + b, 0);
|
|
406
|
+
const appMs = Math.max(0, p.durationMs - tracedMs);
|
|
407
|
+
const total = Math.max(p.durationMs, .1);
|
|
408
|
+
md += `#### Timing\n\n| Category | Duration | % |\n|----------|----------|---|\n`;
|
|
409
|
+
md += `| **Total** | **${p.durationMs}ms** | 100% |\n`;
|
|
410
|
+
for (const [kind, ms] of byKind) md += `| ${kind} | ${round(ms)}ms | ${round(ms / total * 100)}% |\n`;
|
|
411
|
+
md += `| App Logic | ${round(appMs)}ms | ${round(appMs / total * 100)}% |\n\n`;
|
|
412
|
+
for (let j = 0; j < p.spans.length; j++) {
|
|
413
|
+
const s = p.spans[j];
|
|
414
|
+
const offset = s.startOffsetMs != null ? ` (at +${s.startOffsetMs}ms)` : "";
|
|
415
|
+
const err = s.error ? ` ❌ ${s.error}` : "";
|
|
416
|
+
md += `**${j + 1}. [${s.kind}] ${s.name}** — ${s.durationMs}ms${offset}${err}\n`;
|
|
417
|
+
if (s.detail) md += `\`\`\`\n${s.detail}\n\`\`\`\n`;
|
|
418
|
+
}
|
|
419
|
+
md += "\n";
|
|
420
|
+
}
|
|
421
|
+
if (p.error) md += `#### Error\n\n\`\`\`\n${p.error}\n\`\`\`\n\n`;
|
|
422
|
+
}
|
|
423
|
+
md += `---\n\n**Analyze this request and suggest performance optimizations:**\n`;
|
|
424
|
+
md += `- Redundant or slow operations that could be combined?\n`;
|
|
425
|
+
md += `- N+1 query pattern?\n`;
|
|
426
|
+
md += `- Data that should be cached?\n`;
|
|
427
|
+
md += `- Sequential calls that could run in parallel?\n`;
|
|
428
|
+
if (r.durationMs > 100) md += `- ⚠️ This request took ${r.durationMs}ms — what is the bottleneck?\n`;
|
|
429
|
+
return md;
|
|
430
|
+
}
|
|
431
|
+
function safeStringify(v) {
|
|
432
|
+
try {
|
|
433
|
+
return JSON.stringify(v, null, 2);
|
|
434
|
+
} catch {
|
|
435
|
+
return String(v);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
const __analytics_dirname = dirname(fileURLToPath(import.meta.url));
|
|
439
|
+
let _dashboardCache;
|
|
440
|
+
function analyticsHTML() {
|
|
441
|
+
if (_dashboardCache) return _dashboardCache;
|
|
442
|
+
const candidates = [
|
|
443
|
+
resolve(__analytics_dirname, "../../lib/dashboard/index.html"),
|
|
444
|
+
resolve(__analytics_dirname, "../lib/dashboard/index.html"),
|
|
445
|
+
resolve(__analytics_dirname, "../../../lib/dashboard/index.html")
|
|
446
|
+
];
|
|
447
|
+
for (const p of candidates) try {
|
|
448
|
+
_dashboardCache = readFileSync(p, "utf-8");
|
|
449
|
+
return _dashboardCache;
|
|
450
|
+
} catch {}
|
|
451
|
+
return FALLBACK_HTML;
|
|
452
|
+
}
|
|
453
|
+
const FALLBACK_HTML = `<!DOCTYPE html>
|
|
454
|
+
<html lang="en"><head><meta charset="utf-8"><title>Silgi Analytics</title>
|
|
455
|
+
<style>body{font-family:monospace;background:#0a0a0a;color:#e4e4e7;display:flex;align-items:center;justify-content:center;height:100vh;margin:0}
|
|
456
|
+
.msg{text-align:center}.msg h1{color:#edc462;margin-bottom:8px}.msg p{color:#71717a;font-size:14px}</style></head>
|
|
457
|
+
<body><div class="msg"><h1>silgi analytics</h1><p>Dashboard not built. Run <code>pnpm build:dashboard</code></p></div></body></html>`;
|
|
458
|
+
//#endregion
|
|
459
|
+
export { AnalyticsCollector, RequestAccumulator, RequestTrace, analyticsHTML, errorToMarkdown, requestToMarkdown, trace };
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { RouterDef } from "../types.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/plugins/batch-server.d.ts
|
|
4
|
+
interface BatchHandlerOptions<TCtx extends Record<string, unknown>> {
|
|
5
|
+
/** Context factory — called once per batch request */
|
|
6
|
+
context: (req: Request) => TCtx | Promise<TCtx>;
|
|
7
|
+
/** Maximum number of calls in a single batch. Default: 50 */
|
|
8
|
+
maxBatchSize?: number;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Create a Fetch API handler that processes batched RPC calls.
|
|
12
|
+
*
|
|
13
|
+
* Expects a POST with JSON body: `[{ path, input }, ...]`
|
|
14
|
+
* Returns: `[{ data } | { error }, ...]`
|
|
15
|
+
*
|
|
16
|
+
* All calls in a batch share the same context (computed once).
|
|
17
|
+
*/
|
|
18
|
+
declare function createBatchHandler<TCtx extends Record<string, unknown>>(router: RouterDef, options: BatchHandlerOptions<TCtx>): (request: Request) => Promise<Response>;
|
|
19
|
+
//#endregion
|
|
20
|
+
export { BatchHandlerOptions, createBatchHandler };
|