@kanca-app/ikas 0.1.1

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Kanca
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,173 @@
1
+ # @kanca-app/ikas
2
+
3
+ Observability for [ikas](https://ikas.com) apps: webhook deliveries, Admin API
4
+ calls, installs/uninstalls and admin UI web vitals — shown in the
5
+ [Kanca](https://kanca.onurhan.dev) dashboard.
6
+
7
+ - **Metadata only.** Never payloads, variables, responses, headers or tokens.
8
+ - **Never breaks your app.** No throws, no added latency, responses untouched.
9
+ - Node 18+, Next.js App Router, any runtime with `fetch`/`Request`. No runtime
10
+ dependencies on the server (`web-vitals` is used by the browser entry only).
11
+
12
+ ```sh
13
+ pnpm add @kanca-app/ikas
14
+ ```
15
+
16
+ ## Environment
17
+
18
+ | Variable | Where | Value |
19
+ | ------------------------------ | ------------- | ------------------------------------ |
20
+ | `KANCA_KEY` | backend only | server key `kk_...` |
21
+ | `NEXT_PUBLIC_KANCA_PUBLIC_KEY` | browser | public key `kp_...` (vitals only) |
22
+
23
+ Without `KANCA_KEY` the SDK is a silent no-op, so local dev and CI need no setup.
24
+
25
+ ## Integrate the ikas starter app (3 steps)
26
+
27
+ Create the client once — `src/lib/kanca.ts`:
28
+
29
+ ```ts
30
+ import { createKanca } from '@kanca-app/ikas';
31
+ export const kanca = createKanca({ key: process.env.KANCA_KEY });
32
+ ```
33
+
34
+ ### 1. Webhooks — `src/app/api/webhooks/ikas/route.ts`
35
+
36
+ Wrap your route handler. Each delivery records scope, delivery id, merchant id,
37
+ your response status and duration. Subscribe to `store/app/deleted` to get
38
+ uninstalls for free.
39
+
40
+ ```ts
41
+ import { kanca } from '@/lib/kanca';
42
+ import { validateIkasWebhookSignature, type IkasWebhook } from '@ikas/admin-api-client';
43
+ import { NextRequest, NextResponse } from 'next/server';
44
+
45
+ export const POST = kanca.webhook(async (request: NextRequest) => {
46
+ const webhook = (await request.json()) as IkasWebhook; // body is still yours to read
47
+ if (!validateIkasWebhookSignature(webhook, process.env.CLIENT_SECRET!)) {
48
+ return NextResponse.json({ error: 'invalid signature' }, { status: 401 });
49
+ }
50
+ // ... handle webhook.scope
51
+ return NextResponse.json({ ok: true }); // must be 200 — ikas retries anything else, even 201/204
52
+ });
53
+ ```
54
+
55
+ ### 2. Installs — `src/app/api/oauth/callback/ikas/route.ts`
56
+
57
+ After `await AuthTokenManager.put(token);`:
58
+
59
+ ```ts
60
+ kanca.track('install', { merchantId, storeName: merchantResponse.data.getMerchant.storeName });
61
+ await kanca.flush();
62
+ ```
63
+
64
+ ### 3. Admin API calls — `src/helpers/api-helpers.ts`
65
+
66
+ Wrap the client in `getIkas()`:
67
+
68
+ ```ts
69
+ export function getIkas(token: AuthToken) {
70
+ return kanca.instrument(
71
+ new ikasAdminGraphQLAPIClient<AuthToken>({
72
+ graphApiUrl: config.graphApiUrl!,
73
+ accessToken: token.accessToken,
74
+ tokenData: token, // token.merchantId is attached to each call
75
+ onCheckToken: () => onCheckToken(token),
76
+ }),
77
+ );
78
+ }
79
+ ```
80
+
81
+ Every `ikas.queries.*` / `query()` / `mutate()` call becomes a `graphql` event
82
+ named after the operation (`getMerchant`, `listOrder`, …). A `200` response with
83
+ an `errors` array counts as a failure, with `errors[0].extensions.code` as the
84
+ error code.
85
+
86
+ `instrument()` works with `@ikas/admin-api-client` 2.x (any
87
+ `BaseGraphQLAPIClient` subclass) and survives token refreshes. For anything else
88
+ — your own GraphQL client, `graphql-request`, REST calls — use the drop-in
89
+ `kanca.fetch`:
90
+
91
+ ```ts
92
+ const res = await kanca.fetch('https://api.myikas.com/api/v1/admin/graphql', { method: 'POST', headers, body });
93
+ ```
94
+
95
+ ikas GraphQL URLs (`*.myikas.com/…graphql`) become `graphql` events; any other URL
96
+ becomes a `rest` event named `"POST api.example.com/v1/items"` (no query string).
97
+
98
+ Full example files: [`examples/starter-app`](./examples/starter-app).
99
+
100
+ ## Admin UI web vitals
101
+
102
+ ```tsx
103
+ // src/app/layout.tsx
104
+ import { KancaVitals } from '@kanca-app/ikas/react';
105
+
106
+ <body>
107
+ <KancaVitals publicKey={process.env.NEXT_PUBLIC_KANCA_PUBLIC_KEY} />
108
+ {children}
109
+ </body>;
110
+ ```
111
+
112
+ Records LCP, INP, CLS, FCP and TTFB (final values, once per page load) with the
113
+ pathname and rating, sent with `fetch` + `keepalive` when the page is hidden
114
+ (`sendBeacon` only as a fallback — content blockers drop beacons silently).
115
+ Without React: `import { reportVitals } from '@kanca-app/ikas/browser'`.
116
+
117
+ ## Serverless
118
+
119
+ Events are batched in memory and sent every 2 s (or at 100 events), and on
120
+ process exit. After each recorded event the SDK schedules a send without awaiting it.
121
+ On platforms that freeze the function after the response (Vercel, Lambda),
122
+ pass `waitUntil` so webhook, Admin API and install events all survive the freeze:
123
+
124
+ ```ts
125
+ import { waitUntil } from '@vercel/functions';
126
+ export const kanca = createKanca({ key: process.env.KANCA_KEY, waitUntil });
127
+ ```
128
+
129
+ or `await kanca.flush()` before returning (it never throws).
130
+
131
+ ## Options
132
+
133
+ ```ts
134
+ createKanca({
135
+ key, // kk_...
136
+ endpoint, // default https://kanca.onurhan.dev
137
+ debug, // log activity to the console
138
+ disabled, // force no-op
139
+ flushIntervalMs, // default 2000
140
+ maxBatchSize, // default 100, max 500
141
+ maxQueueSize, // default 5000; oldest events dropped beyond this
142
+ waitUntil, // (promise) => void
143
+ });
144
+ ```
145
+
146
+ Failed sends are retried on 429/5xx/network errors (exponential backoff, 3
147
+ attempts). Every event has a UUID, so retries never double count.
148
+
149
+ ## Privacy
150
+
151
+ What is sent: event type, name (webhook scope / GraphQL operation name /
152
+ `METHOD host/path`), HTTP status, ok, duration, error code, error message
153
+ (≤ 500 chars), merchant id, webhook delivery id, and for installs the store
154
+ name. Vitals send metric name, value, rating and pathname.
155
+
156
+ What is never sent: webhook bodies, the webhook signature, GraphQL queries,
157
+ variables or responses, request/response headers, access tokens, query strings,
158
+ customer or order data. Kanca never asks for your ikas client secret.
159
+
160
+ ## Honest limits
161
+
162
+ - The SDK sees what reaches your app. Deliveries that never arrive (app down,
163
+ DNS, TLS, timeouts before your handler runs) are invisible — the dashboard
164
+ shows failures *seen by your app*.
165
+ - For `instrument()`, `@ikas/admin-api-client` does not expose the HTTP status
166
+ of successful calls, so those events carry `ok: true` without a status. Use
167
+ `kanca.fetch` if you need exact statuses.
168
+ - Events buffered in memory are lost if the process is killed hard (SIGKILL,
169
+ OOM) before the next send.
170
+
171
+ ## License
172
+
173
+ MIT
@@ -0,0 +1,172 @@
1
+ "use strict";
2
+ var __defProp = Object.defineProperty;
3
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
4
+ var __getOwnPropNames = Object.getOwnPropertyNames;
5
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
6
+ var __export = (target, all) => {
7
+ for (var name in all)
8
+ __defProp(target, name, { get: all[name], enumerable: true });
9
+ };
10
+ var __copyProps = (to, from, except, desc) => {
11
+ if (from && typeof from === "object" || typeof from === "function") {
12
+ for (let key of __getOwnPropNames(from))
13
+ if (!__hasOwnProp.call(to, key) && key !== except)
14
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
15
+ }
16
+ return to;
17
+ };
18
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
19
+
20
+ // src/browser.ts
21
+ var browser_exports = {};
22
+ __export(browser_exports, {
23
+ __resetVitalsForTests: () => __resetVitalsForTests,
24
+ reportVitals: () => reportVitals
25
+ });
26
+ module.exports = __toCommonJS(browser_exports);
27
+ var import_web_vitals = require("web-vitals");
28
+
29
+ // src/shared.ts
30
+ var SDK_NAME = "@kanca-app/ikas";
31
+ var SDK_VERSION = "0.1.1";
32
+ var DEFAULT_ENDPOINT = "https://kanca.onurhan.dev";
33
+ var MAX_NAME = 200;
34
+ var MAX_ERROR_MESSAGE = 500;
35
+ var MAX_ERROR_CODE = 100;
36
+ var MAX_ATTR_KEYS = 20;
37
+ var MAX_ATTR_STRING = 500;
38
+ var MAX_ATTR_KEY = 64;
39
+ var MAX_BATCH_BYTES = 480 * 1024;
40
+ function truncate(s, max) {
41
+ return s.length > max ? s.slice(0, max) : s;
42
+ }
43
+ function hex(bytes) {
44
+ let out = "";
45
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
46
+ return out;
47
+ }
48
+ function uuid() {
49
+ const c = globalThis.crypto;
50
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
51
+ const b = new Uint8Array(16);
52
+ if (c && typeof c.getRandomValues === "function") c.getRandomValues(b);
53
+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
54
+ b[6] = b[6] & 15 | 64;
55
+ b[8] = b[8] & 63 | 128;
56
+ const h = hex(b);
57
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
58
+ }
59
+ function isFiniteNumber(v) {
60
+ return typeof v === "number" && Number.isFinite(v);
61
+ }
62
+ function cleanAttrs(attrs) {
63
+ if (!attrs || typeof attrs !== "object") return void 0;
64
+ const out = {};
65
+ let n = 0;
66
+ for (const [k, v] of Object.entries(attrs)) {
67
+ if (n >= MAX_ATTR_KEYS) break;
68
+ if (v === void 0 || v === null || !k) continue;
69
+ let val;
70
+ if (typeof v === "boolean") val = v;
71
+ else if (typeof v === "number") {
72
+ if (!Number.isFinite(v)) continue;
73
+ val = v;
74
+ } else if (typeof v === "string") val = truncate(v, MAX_ATTR_STRING);
75
+ else continue;
76
+ out[truncate(k, MAX_ATTR_KEY)] = val;
77
+ n++;
78
+ }
79
+ return n > 0 ? out : void 0;
80
+ }
81
+ function sanitizeEvent(e) {
82
+ const out = {
83
+ id: typeof e.id === "string" && e.id ? e.id : uuid(),
84
+ type: e.type,
85
+ ts: isFiniteNumber(e.ts) ? Math.round(e.ts) : Date.now(),
86
+ name: truncate(typeof e.name === "string" && e.name ? e.name : "unknown", MAX_NAME)
87
+ };
88
+ if (isFiniteNumber(e.status) && e.status >= 100 && e.status <= 599) out.status = Math.trunc(e.status);
89
+ if (typeof e.ok === "boolean") out.ok = e.ok;
90
+ if (isFiniteNumber(e.durationMs)) out.durationMs = Math.min(864e5, Math.max(0, Math.round(e.durationMs * 10) / 10));
91
+ if (isFiniteNumber(e.value)) out.value = e.value;
92
+ if (e.error && typeof e.error.code === "string") {
93
+ out.error = { code: truncate(e.error.code, MAX_ERROR_CODE) };
94
+ if (typeof e.error.message === "string" && e.error.message) {
95
+ out.error.message = truncate(e.error.message, MAX_ERROR_MESSAGE);
96
+ }
97
+ }
98
+ if (typeof e.merchantId === "string" && e.merchantId) out.merchantId = truncate(e.merchantId, 200);
99
+ if (typeof e.deliveryId === "string" && e.deliveryId) out.deliveryId = truncate(e.deliveryId, 200);
100
+ const attrs = cleanAttrs(e.attrs);
101
+ if (attrs) out.attrs = attrs;
102
+ return out;
103
+ }
104
+ function joinUrl(endpoint, path) {
105
+ return endpoint.replace(/\/+$/, "") + path;
106
+ }
107
+ var encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : void 0;
108
+
109
+ // src/browser.ts
110
+ var VITALS = ["LCP", "INP", "CLS", "FCP", "TTFB"];
111
+ var FLAG = "__kancaVitalsStarted";
112
+ var g = globalThis;
113
+ function reportVitals(options) {
114
+ try {
115
+ if (g[FLAG] || typeof window === "undefined" || typeof document === "undefined") return;
116
+ const key = typeof options?.publicKey === "string" ? options.publicKey.trim() : "";
117
+ const debug = !!options?.debug;
118
+ if (!key.startsWith("kp_")) {
119
+ if (debug || key) console.warn("[kanca] reportVitals(): a public key (kp_...) is required; vitals disabled.");
120
+ return;
121
+ }
122
+ g[FLAG] = true;
123
+ const url = `${joinUrl(options.endpoint || DEFAULT_ENDPOINT, "/api/ingest/browser")}?key=${encodeURIComponent(key)}`;
124
+ const path = location.pathname;
125
+ const ts = typeof performance !== "undefined" && typeof performance.timeOrigin === "number" && performance.timeOrigin > 0 ? Math.round(performance.timeOrigin) : Date.now();
126
+ const queue = [];
127
+ const seen = /* @__PURE__ */ new Set();
128
+ const send = () => {
129
+ if (!queue.length) return;
130
+ const events = queue.splice(0, queue.length);
131
+ const body = JSON.stringify({ sdk: { name: SDK_NAME, version: SDK_VERSION }, events });
132
+ if (debug) console.debug("[kanca] vitals", events);
133
+ try {
134
+ if (typeof fetch === "function") {
135
+ void fetch(url, { method: "POST", body, keepalive: true, mode: "cors", credentials: "omit", headers: { "content-type": "text/plain" } }).catch(() => void 0);
136
+ return;
137
+ }
138
+ } catch {
139
+ }
140
+ try {
141
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") navigator.sendBeacon(url, body);
142
+ } catch {
143
+ }
144
+ };
145
+ const onMetric = (m) => {
146
+ try {
147
+ if (!VITALS.includes(m.name) || seen.has(m.name)) return;
148
+ seen.add(m.name);
149
+ queue.push(sanitizeEvent({ id: uuid(), type: "vital", ts, name: m.name, value: m.value, attrs: { path, rating: m.rating } }));
150
+ } catch {
151
+ }
152
+ };
153
+ (0, import_web_vitals.onLCP)(onMetric);
154
+ (0, import_web_vitals.onINP)(onMetric);
155
+ (0, import_web_vitals.onCLS)(onMetric);
156
+ (0, import_web_vitals.onFCP)(onMetric);
157
+ (0, import_web_vitals.onTTFB)(onMetric);
158
+ addEventListener(
159
+ "visibilitychange",
160
+ () => {
161
+ if (document.visibilityState === "hidden") queueMicrotask(send);
162
+ },
163
+ true
164
+ );
165
+ addEventListener("pagehide", send, true);
166
+ } catch {
167
+ }
168
+ }
169
+ function __resetVitalsForTests() {
170
+ g[FLAG] = false;
171
+ }
172
+ //# sourceMappingURL=browser.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/browser.ts","../src/shared.ts"],"sourcesContent":["// @kanca-app/ikas/browser — Core Web Vitals for the ikas admin iframe.\n// Sends only { name, value, rating, pathname } per metric. Uses a public key (kp_...).\n\nimport { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from \"web-vitals\";\nimport { DEFAULT_ENDPOINT, SDK_NAME, SDK_VERSION, joinUrl, sanitizeEvent, uuid, type KancaEvent } from \"./shared\";\n\nexport interface ReportVitalsOptions {\n /** Public key (`kp_...`). Safe to expose in the browser; only accepts `vital` events. */\n publicKey: string | undefined;\n /** Kanca base URL. Default `https://kanca.onurhan.dev`. */\n endpoint?: string;\n debug?: boolean;\n}\n\nconst VITALS = [\"LCP\", \"INP\", \"CLS\", \"FCP\", \"TTFB\"] as const;\n\n// Global (not module) flag so duplicate copies (ESM + CJS, browser + react bundles) still start once.\nconst FLAG = \"__kancaVitalsStarted\";\nconst g = globalThis as { [FLAG]?: boolean };\n\n/**\n * Starts collecting LCP, INP, CLS, FCP and TTFB (final values, once per page\n * load per metric) and sends them when the page is hidden or unloaded.\n * Safe to call more than once — only the first call has an effect.\n */\nexport function reportVitals(options: ReportVitalsOptions): void {\n try {\n if (g[FLAG] || typeof window === \"undefined\" || typeof document === \"undefined\") return;\n const key = typeof options?.publicKey === \"string\" ? options.publicKey.trim() : \"\";\n const debug = !!options?.debug;\n if (!key.startsWith(\"kp_\")) {\n if (debug || key) console.warn(\"[kanca] reportVitals(): a public key (kp_...) is required; vitals disabled.\");\n return;\n }\n g[FLAG] = true;\n\n const url = `${joinUrl(options.endpoint || DEFAULT_ENDPOINT, \"/api/ingest/browser\")}?key=${encodeURIComponent(key)}`;\n const path = location.pathname;\n const ts =\n typeof performance !== \"undefined\" && typeof performance.timeOrigin === \"number\" && performance.timeOrigin > 0\n ? Math.round(performance.timeOrigin)\n : Date.now();\n const queue: KancaEvent[] = [];\n const seen = new Set<string>();\n\n const send = () => {\n if (!queue.length) return;\n const events = queue.splice(0, queue.length);\n const body = JSON.stringify({ sdk: { name: SDK_NAME, version: SDK_VERSION }, events });\n if (debug) console.debug(\"[kanca] vitals\", events);\n // fetch+keepalive first: content blockers drop sendBeacon (\"ping\") requests\n // while sendBeacon still returns true, so vitals would vanish silently.\n // A text/plain body keeps it a simple request (no CORS preflight).\n try {\n if (typeof fetch === \"function\") {\n void fetch(url, { method: \"POST\", body, keepalive: true, mode: \"cors\", credentials: \"omit\", headers: { \"content-type\": \"text/plain\" } }).catch(() => undefined);\n return;\n }\n } catch {\n /* fall through */\n }\n try {\n if (typeof navigator !== \"undefined\" && typeof navigator.sendBeacon === \"function\") navigator.sendBeacon(url, body);\n } catch {\n /* never throw */\n }\n };\n\n const onMetric = (m: Metric) => {\n try {\n if (!(VITALS as readonly string[]).includes(m.name) || seen.has(m.name)) return;\n seen.add(m.name);\n queue.push(sanitizeEvent({ id: uuid(), type: \"vital\", ts, name: m.name, value: m.value, attrs: { path, rating: m.rating } }));\n } catch {\n /* ignore */\n }\n };\n\n // Without reportAllChanges, web-vitals reports each metric's final value.\n onLCP(onMetric);\n onINP(onMetric);\n onCLS(onMetric);\n onFCP(onMetric);\n onTTFB(onMetric);\n\n // Registered after web-vitals' own listeners, so final values are queued before we send.\n addEventListener(\n \"visibilitychange\",\n () => {\n // Microtask: lets any report web-vitals triggers on this same event land first.\n if (document.visibilityState === \"hidden\") queueMicrotask(send);\n },\n true,\n );\n addEventListener(\"pagehide\", send, true);\n } catch {\n /* never break the host page */\n }\n}\n\n/** @internal for tests */\nexport function __resetVitalsForTests(): void {\n g[FLAG] = false;\n}\n","// Shared, runtime-agnostic helpers (used by both the Node and browser entries).\n// Wire format: see docs/PROTOCOL.md (v1).\n\nexport const SDK_NAME = \"@kanca-app/ikas\";\nexport const SDK_VERSION = \"0.1.1\";\nexport const DEFAULT_ENDPOINT = \"https://kanca.onurhan.dev\";\n\nexport const MAX_NAME = 200;\nexport const MAX_ERROR_MESSAGE = 500;\nexport const MAX_ERROR_CODE = 100;\nexport const MAX_ATTR_KEYS = 20;\nexport const MAX_ATTR_STRING = 500;\nexport const MAX_ATTR_KEY = 64;\nexport const MAX_EVENTS_PER_BATCH = 500;\n/** Protocol limit is 512 KB; keep headroom for the envelope. */\nexport const MAX_BATCH_BYTES = 480 * 1024;\n\nexport type EventType = \"webhook\" | \"graphql\" | \"rest\" | \"install\" | \"uninstall\" | \"vital\";\n\nexport type AttrValue = string | number | boolean;\n\nexport interface KancaError {\n code: string;\n message?: string;\n}\n\n/** A single wire event (PROTOCOL.md → Event). */\nexport interface KancaEvent {\n id: string;\n type: EventType;\n ts: number;\n name: string;\n status?: number;\n ok?: boolean;\n durationMs?: number;\n value?: number;\n error?: KancaError;\n merchantId?: string;\n deliveryId?: string;\n attrs?: Record<string, AttrValue>;\n}\n\nexport interface Batch {\n sdk: { name: string; version: string };\n events: KancaEvent[];\n}\n\nexport function truncate(s: string, max: number): string {\n return s.length > max ? s.slice(0, max) : s;\n}\n\nfunction hex(bytes: Uint8Array): string {\n let out = \"\";\n for (const b of bytes) out += b.toString(16).padStart(2, \"0\");\n return out;\n}\n\n/** UUID v4 via crypto.randomUUID, with a getRandomValues fallback (non-secure browser contexts). */\nexport function uuid(): string {\n const c = (globalThis as { crypto?: Crypto }).crypto;\n if (c && typeof c.randomUUID === \"function\") return c.randomUUID();\n const b = new Uint8Array(16);\n if (c && typeof c.getRandomValues === \"function\") c.getRandomValues(b);\n else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);\n b[6] = (b[6]! & 0x0f) | 0x40;\n b[8] = (b[8]! & 0x3f) | 0x80;\n const h = hex(b);\n return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;\n}\n\nfunction isFiniteNumber(v: unknown): v is number {\n return typeof v === \"number\" && Number.isFinite(v);\n}\n\nfunction cleanAttrs(attrs: unknown): Record<string, AttrValue> | undefined {\n if (!attrs || typeof attrs !== \"object\") return undefined;\n const out: Record<string, AttrValue> = {};\n let n = 0;\n for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {\n if (n >= MAX_ATTR_KEYS) break;\n if (v === undefined || v === null || !k) continue;\n let val: AttrValue;\n if (typeof v === \"boolean\") val = v;\n else if (typeof v === \"number\") {\n if (!Number.isFinite(v)) continue;\n val = v;\n } else if (typeof v === \"string\") val = truncate(v, MAX_ATTR_STRING);\n else continue; // attrs are flat: nested values are dropped, never stringified\n out[truncate(k, MAX_ATTR_KEY)] = val;\n n++;\n }\n return n > 0 ? out : undefined;\n}\n\n/**\n * Normalises an event so it always satisfies the protocol limits. Unknown\n * fields are dropped — only the whitelisted metadata below ever leaves the process.\n */\nexport function sanitizeEvent(e: KancaEvent): KancaEvent {\n const out: KancaEvent = {\n id: typeof e.id === \"string\" && e.id ? e.id : uuid(),\n type: e.type,\n ts: isFiniteNumber(e.ts) ? Math.round(e.ts) : Date.now(),\n name: truncate(typeof e.name === \"string\" && e.name ? e.name : \"unknown\", MAX_NAME),\n };\n if (isFiniteNumber(e.status) && e.status >= 100 && e.status <= 599) out.status = Math.trunc(e.status);\n if (typeof e.ok === \"boolean\") out.ok = e.ok;\n if (isFiniteNumber(e.durationMs)) out.durationMs = Math.min(86_400_000, Math.max(0, Math.round(e.durationMs * 10) / 10));\n if (isFiniteNumber(e.value)) out.value = e.value;\n if (e.error && typeof e.error.code === \"string\") {\n out.error = { code: truncate(e.error.code, MAX_ERROR_CODE) };\n if (typeof e.error.message === \"string\" && e.error.message) {\n out.error.message = truncate(e.error.message, MAX_ERROR_MESSAGE);\n }\n }\n if (typeof e.merchantId === \"string\" && e.merchantId) out.merchantId = truncate(e.merchantId, 200);\n if (typeof e.deliveryId === \"string\" && e.deliveryId) out.deliveryId = truncate(e.deliveryId, 200);\n const attrs = cleanAttrs(e.attrs);\n if (attrs) out.attrs = attrs;\n return out;\n}\n\nexport function errorMessage(err: unknown): string | undefined {\n try {\n if (err instanceof Error) return truncate(err.message || err.name, MAX_ERROR_MESSAGE);\n if (typeof err === \"string\") return truncate(err, MAX_ERROR_MESSAGE);\n } catch {\n /* ignore */\n }\n return undefined;\n}\n\nexport function joinUrl(endpoint: string, path: string): string {\n return endpoint.replace(/\\/+$/, \"\") + path;\n}\n\nconst encoder = typeof TextEncoder !== \"undefined\" ? new TextEncoder() : undefined;\nexport function byteLength(s: string): number {\n return encoder ? encoder.encode(s).length : s.length * 3;\n}\n\n/**\n * Splits events into protocol-sized batches (<= maxEvents events and\n * <= MAX_BATCH_BYTES serialized). Returns serialized bodies.\n */\nexport function buildBodies(events: KancaEvent[], maxEvents: number): { body: string; events: KancaEvent[] }[] {\n const sdk = { name: SDK_NAME, version: SDK_VERSION };\n const envelope = byteLength(JSON.stringify({ sdk, events: [] }));\n const out: { body: string; events: KancaEvent[] }[] = [];\n let cur: KancaEvent[] = [];\n let size = envelope;\n const push = () => {\n if (cur.length) out.push({ body: JSON.stringify({ sdk, events: cur } satisfies Batch), events: cur });\n cur = [];\n size = envelope;\n };\n for (const ev of events) {\n const len = byteLength(JSON.stringify(ev)) + 1;\n if (cur.length && (cur.length >= maxEvents || size + len > MAX_BATCH_BYTES)) push();\n cur.push(ev);\n size += len;\n }\n push();\n return out;\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,17 @@
1
+ interface ReportVitalsOptions {
2
+ /** Public key (`kp_...`). Safe to expose in the browser; only accepts `vital` events. */
3
+ publicKey: string | undefined;
4
+ /** Kanca base URL. Default `https://kanca.onurhan.dev`. */
5
+ endpoint?: string;
6
+ debug?: boolean;
7
+ }
8
+ /**
9
+ * Starts collecting LCP, INP, CLS, FCP and TTFB (final values, once per page
10
+ * load per metric) and sends them when the page is hidden or unloaded.
11
+ * Safe to call more than once — only the first call has an effect.
12
+ */
13
+ declare function reportVitals(options: ReportVitalsOptions): void;
14
+ /** @internal for tests */
15
+ declare function __resetVitalsForTests(): void;
16
+
17
+ export { type ReportVitalsOptions, __resetVitalsForTests, reportVitals };
@@ -0,0 +1,17 @@
1
+ interface ReportVitalsOptions {
2
+ /** Public key (`kp_...`). Safe to expose in the browser; only accepts `vital` events. */
3
+ publicKey: string | undefined;
4
+ /** Kanca base URL. Default `https://kanca.onurhan.dev`. */
5
+ endpoint?: string;
6
+ debug?: boolean;
7
+ }
8
+ /**
9
+ * Starts collecting LCP, INP, CLS, FCP and TTFB (final values, once per page
10
+ * load per metric) and sends them when the page is hidden or unloaded.
11
+ * Safe to call more than once — only the first call has an effect.
12
+ */
13
+ declare function reportVitals(options: ReportVitalsOptions): void;
14
+ /** @internal for tests */
15
+ declare function __resetVitalsForTests(): void;
16
+
17
+ export { type ReportVitalsOptions, __resetVitalsForTests, reportVitals };
@@ -0,0 +1,151 @@
1
+ // src/browser.ts
2
+ import { onCLS, onFCP, onINP, onLCP, onTTFB } from "web-vitals";
3
+
4
+ // src/shared.ts
5
+ var SDK_NAME = "@kanca-app/ikas";
6
+ var SDK_VERSION = "0.1.1";
7
+ var DEFAULT_ENDPOINT = "https://kanca.onurhan.dev";
8
+ var MAX_NAME = 200;
9
+ var MAX_ERROR_MESSAGE = 500;
10
+ var MAX_ERROR_CODE = 100;
11
+ var MAX_ATTR_KEYS = 20;
12
+ var MAX_ATTR_STRING = 500;
13
+ var MAX_ATTR_KEY = 64;
14
+ var MAX_BATCH_BYTES = 480 * 1024;
15
+ function truncate(s, max) {
16
+ return s.length > max ? s.slice(0, max) : s;
17
+ }
18
+ function hex(bytes) {
19
+ let out = "";
20
+ for (const b of bytes) out += b.toString(16).padStart(2, "0");
21
+ return out;
22
+ }
23
+ function uuid() {
24
+ const c = globalThis.crypto;
25
+ if (c && typeof c.randomUUID === "function") return c.randomUUID();
26
+ const b = new Uint8Array(16);
27
+ if (c && typeof c.getRandomValues === "function") c.getRandomValues(b);
28
+ else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);
29
+ b[6] = b[6] & 15 | 64;
30
+ b[8] = b[8] & 63 | 128;
31
+ const h = hex(b);
32
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
33
+ }
34
+ function isFiniteNumber(v) {
35
+ return typeof v === "number" && Number.isFinite(v);
36
+ }
37
+ function cleanAttrs(attrs) {
38
+ if (!attrs || typeof attrs !== "object") return void 0;
39
+ const out = {};
40
+ let n = 0;
41
+ for (const [k, v] of Object.entries(attrs)) {
42
+ if (n >= MAX_ATTR_KEYS) break;
43
+ if (v === void 0 || v === null || !k) continue;
44
+ let val;
45
+ if (typeof v === "boolean") val = v;
46
+ else if (typeof v === "number") {
47
+ if (!Number.isFinite(v)) continue;
48
+ val = v;
49
+ } else if (typeof v === "string") val = truncate(v, MAX_ATTR_STRING);
50
+ else continue;
51
+ out[truncate(k, MAX_ATTR_KEY)] = val;
52
+ n++;
53
+ }
54
+ return n > 0 ? out : void 0;
55
+ }
56
+ function sanitizeEvent(e) {
57
+ const out = {
58
+ id: typeof e.id === "string" && e.id ? e.id : uuid(),
59
+ type: e.type,
60
+ ts: isFiniteNumber(e.ts) ? Math.round(e.ts) : Date.now(),
61
+ name: truncate(typeof e.name === "string" && e.name ? e.name : "unknown", MAX_NAME)
62
+ };
63
+ if (isFiniteNumber(e.status) && e.status >= 100 && e.status <= 599) out.status = Math.trunc(e.status);
64
+ if (typeof e.ok === "boolean") out.ok = e.ok;
65
+ if (isFiniteNumber(e.durationMs)) out.durationMs = Math.min(864e5, Math.max(0, Math.round(e.durationMs * 10) / 10));
66
+ if (isFiniteNumber(e.value)) out.value = e.value;
67
+ if (e.error && typeof e.error.code === "string") {
68
+ out.error = { code: truncate(e.error.code, MAX_ERROR_CODE) };
69
+ if (typeof e.error.message === "string" && e.error.message) {
70
+ out.error.message = truncate(e.error.message, MAX_ERROR_MESSAGE);
71
+ }
72
+ }
73
+ if (typeof e.merchantId === "string" && e.merchantId) out.merchantId = truncate(e.merchantId, 200);
74
+ if (typeof e.deliveryId === "string" && e.deliveryId) out.deliveryId = truncate(e.deliveryId, 200);
75
+ const attrs = cleanAttrs(e.attrs);
76
+ if (attrs) out.attrs = attrs;
77
+ return out;
78
+ }
79
+ function joinUrl(endpoint, path) {
80
+ return endpoint.replace(/\/+$/, "") + path;
81
+ }
82
+ var encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : void 0;
83
+
84
+ // src/browser.ts
85
+ var VITALS = ["LCP", "INP", "CLS", "FCP", "TTFB"];
86
+ var FLAG = "__kancaVitalsStarted";
87
+ var g = globalThis;
88
+ function reportVitals(options) {
89
+ try {
90
+ if (g[FLAG] || typeof window === "undefined" || typeof document === "undefined") return;
91
+ const key = typeof options?.publicKey === "string" ? options.publicKey.trim() : "";
92
+ const debug = !!options?.debug;
93
+ if (!key.startsWith("kp_")) {
94
+ if (debug || key) console.warn("[kanca] reportVitals(): a public key (kp_...) is required; vitals disabled.");
95
+ return;
96
+ }
97
+ g[FLAG] = true;
98
+ const url = `${joinUrl(options.endpoint || DEFAULT_ENDPOINT, "/api/ingest/browser")}?key=${encodeURIComponent(key)}`;
99
+ const path = location.pathname;
100
+ const ts = typeof performance !== "undefined" && typeof performance.timeOrigin === "number" && performance.timeOrigin > 0 ? Math.round(performance.timeOrigin) : Date.now();
101
+ const queue = [];
102
+ const seen = /* @__PURE__ */ new Set();
103
+ const send = () => {
104
+ if (!queue.length) return;
105
+ const events = queue.splice(0, queue.length);
106
+ const body = JSON.stringify({ sdk: { name: SDK_NAME, version: SDK_VERSION }, events });
107
+ if (debug) console.debug("[kanca] vitals", events);
108
+ try {
109
+ if (typeof fetch === "function") {
110
+ void fetch(url, { method: "POST", body, keepalive: true, mode: "cors", credentials: "omit", headers: { "content-type": "text/plain" } }).catch(() => void 0);
111
+ return;
112
+ }
113
+ } catch {
114
+ }
115
+ try {
116
+ if (typeof navigator !== "undefined" && typeof navigator.sendBeacon === "function") navigator.sendBeacon(url, body);
117
+ } catch {
118
+ }
119
+ };
120
+ const onMetric = (m) => {
121
+ try {
122
+ if (!VITALS.includes(m.name) || seen.has(m.name)) return;
123
+ seen.add(m.name);
124
+ queue.push(sanitizeEvent({ id: uuid(), type: "vital", ts, name: m.name, value: m.value, attrs: { path, rating: m.rating } }));
125
+ } catch {
126
+ }
127
+ };
128
+ onLCP(onMetric);
129
+ onINP(onMetric);
130
+ onCLS(onMetric);
131
+ onFCP(onMetric);
132
+ onTTFB(onMetric);
133
+ addEventListener(
134
+ "visibilitychange",
135
+ () => {
136
+ if (document.visibilityState === "hidden") queueMicrotask(send);
137
+ },
138
+ true
139
+ );
140
+ addEventListener("pagehide", send, true);
141
+ } catch {
142
+ }
143
+ }
144
+ function __resetVitalsForTests() {
145
+ g[FLAG] = false;
146
+ }
147
+ export {
148
+ __resetVitalsForTests,
149
+ reportVitals
150
+ };
151
+ //# sourceMappingURL=browser.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/browser.ts","../src/shared.ts"],"sourcesContent":["// @kanca-app/ikas/browser — Core Web Vitals for the ikas admin iframe.\n// Sends only { name, value, rating, pathname } per metric. Uses a public key (kp_...).\n\nimport { onCLS, onFCP, onINP, onLCP, onTTFB, type Metric } from \"web-vitals\";\nimport { DEFAULT_ENDPOINT, SDK_NAME, SDK_VERSION, joinUrl, sanitizeEvent, uuid, type KancaEvent } from \"./shared\";\n\nexport interface ReportVitalsOptions {\n /** Public key (`kp_...`). Safe to expose in the browser; only accepts `vital` events. */\n publicKey: string | undefined;\n /** Kanca base URL. Default `https://kanca.onurhan.dev`. */\n endpoint?: string;\n debug?: boolean;\n}\n\nconst VITALS = [\"LCP\", \"INP\", \"CLS\", \"FCP\", \"TTFB\"] as const;\n\n// Global (not module) flag so duplicate copies (ESM + CJS, browser + react bundles) still start once.\nconst FLAG = \"__kancaVitalsStarted\";\nconst g = globalThis as { [FLAG]?: boolean };\n\n/**\n * Starts collecting LCP, INP, CLS, FCP and TTFB (final values, once per page\n * load per metric) and sends them when the page is hidden or unloaded.\n * Safe to call more than once — only the first call has an effect.\n */\nexport function reportVitals(options: ReportVitalsOptions): void {\n try {\n if (g[FLAG] || typeof window === \"undefined\" || typeof document === \"undefined\") return;\n const key = typeof options?.publicKey === \"string\" ? options.publicKey.trim() : \"\";\n const debug = !!options?.debug;\n if (!key.startsWith(\"kp_\")) {\n if (debug || key) console.warn(\"[kanca] reportVitals(): a public key (kp_...) is required; vitals disabled.\");\n return;\n }\n g[FLAG] = true;\n\n const url = `${joinUrl(options.endpoint || DEFAULT_ENDPOINT, \"/api/ingest/browser\")}?key=${encodeURIComponent(key)}`;\n const path = location.pathname;\n const ts =\n typeof performance !== \"undefined\" && typeof performance.timeOrigin === \"number\" && performance.timeOrigin > 0\n ? Math.round(performance.timeOrigin)\n : Date.now();\n const queue: KancaEvent[] = [];\n const seen = new Set<string>();\n\n const send = () => {\n if (!queue.length) return;\n const events = queue.splice(0, queue.length);\n const body = JSON.stringify({ sdk: { name: SDK_NAME, version: SDK_VERSION }, events });\n if (debug) console.debug(\"[kanca] vitals\", events);\n // fetch+keepalive first: content blockers drop sendBeacon (\"ping\") requests\n // while sendBeacon still returns true, so vitals would vanish silently.\n // A text/plain body keeps it a simple request (no CORS preflight).\n try {\n if (typeof fetch === \"function\") {\n void fetch(url, { method: \"POST\", body, keepalive: true, mode: \"cors\", credentials: \"omit\", headers: { \"content-type\": \"text/plain\" } }).catch(() => undefined);\n return;\n }\n } catch {\n /* fall through */\n }\n try {\n if (typeof navigator !== \"undefined\" && typeof navigator.sendBeacon === \"function\") navigator.sendBeacon(url, body);\n } catch {\n /* never throw */\n }\n };\n\n const onMetric = (m: Metric) => {\n try {\n if (!(VITALS as readonly string[]).includes(m.name) || seen.has(m.name)) return;\n seen.add(m.name);\n queue.push(sanitizeEvent({ id: uuid(), type: \"vital\", ts, name: m.name, value: m.value, attrs: { path, rating: m.rating } }));\n } catch {\n /* ignore */\n }\n };\n\n // Without reportAllChanges, web-vitals reports each metric's final value.\n onLCP(onMetric);\n onINP(onMetric);\n onCLS(onMetric);\n onFCP(onMetric);\n onTTFB(onMetric);\n\n // Registered after web-vitals' own listeners, so final values are queued before we send.\n addEventListener(\n \"visibilitychange\",\n () => {\n // Microtask: lets any report web-vitals triggers on this same event land first.\n if (document.visibilityState === \"hidden\") queueMicrotask(send);\n },\n true,\n );\n addEventListener(\"pagehide\", send, true);\n } catch {\n /* never break the host page */\n }\n}\n\n/** @internal for tests */\nexport function __resetVitalsForTests(): void {\n g[FLAG] = false;\n}\n","// Shared, runtime-agnostic helpers (used by both the Node and browser entries).\n// Wire format: see docs/PROTOCOL.md (v1).\n\nexport const SDK_NAME = \"@kanca-app/ikas\";\nexport const SDK_VERSION = \"0.1.1\";\nexport const DEFAULT_ENDPOINT = \"https://kanca.onurhan.dev\";\n\nexport const MAX_NAME = 200;\nexport const MAX_ERROR_MESSAGE = 500;\nexport const MAX_ERROR_CODE = 100;\nexport const MAX_ATTR_KEYS = 20;\nexport const MAX_ATTR_STRING = 500;\nexport const MAX_ATTR_KEY = 64;\nexport const MAX_EVENTS_PER_BATCH = 500;\n/** Protocol limit is 512 KB; keep headroom for the envelope. */\nexport const MAX_BATCH_BYTES = 480 * 1024;\n\nexport type EventType = \"webhook\" | \"graphql\" | \"rest\" | \"install\" | \"uninstall\" | \"vital\";\n\nexport type AttrValue = string | number | boolean;\n\nexport interface KancaError {\n code: string;\n message?: string;\n}\n\n/** A single wire event (PROTOCOL.md → Event). */\nexport interface KancaEvent {\n id: string;\n type: EventType;\n ts: number;\n name: string;\n status?: number;\n ok?: boolean;\n durationMs?: number;\n value?: number;\n error?: KancaError;\n merchantId?: string;\n deliveryId?: string;\n attrs?: Record<string, AttrValue>;\n}\n\nexport interface Batch {\n sdk: { name: string; version: string };\n events: KancaEvent[];\n}\n\nexport function truncate(s: string, max: number): string {\n return s.length > max ? s.slice(0, max) : s;\n}\n\nfunction hex(bytes: Uint8Array): string {\n let out = \"\";\n for (const b of bytes) out += b.toString(16).padStart(2, \"0\");\n return out;\n}\n\n/** UUID v4 via crypto.randomUUID, with a getRandomValues fallback (non-secure browser contexts). */\nexport function uuid(): string {\n const c = (globalThis as { crypto?: Crypto }).crypto;\n if (c && typeof c.randomUUID === \"function\") return c.randomUUID();\n const b = new Uint8Array(16);\n if (c && typeof c.getRandomValues === \"function\") c.getRandomValues(b);\n else for (let i = 0; i < 16; i++) b[i] = Math.floor(Math.random() * 256);\n b[6] = (b[6]! & 0x0f) | 0x40;\n b[8] = (b[8]! & 0x3f) | 0x80;\n const h = hex(b);\n return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;\n}\n\nfunction isFiniteNumber(v: unknown): v is number {\n return typeof v === \"number\" && Number.isFinite(v);\n}\n\nfunction cleanAttrs(attrs: unknown): Record<string, AttrValue> | undefined {\n if (!attrs || typeof attrs !== \"object\") return undefined;\n const out: Record<string, AttrValue> = {};\n let n = 0;\n for (const [k, v] of Object.entries(attrs as Record<string, unknown>)) {\n if (n >= MAX_ATTR_KEYS) break;\n if (v === undefined || v === null || !k) continue;\n let val: AttrValue;\n if (typeof v === \"boolean\") val = v;\n else if (typeof v === \"number\") {\n if (!Number.isFinite(v)) continue;\n val = v;\n } else if (typeof v === \"string\") val = truncate(v, MAX_ATTR_STRING);\n else continue; // attrs are flat: nested values are dropped, never stringified\n out[truncate(k, MAX_ATTR_KEY)] = val;\n n++;\n }\n return n > 0 ? out : undefined;\n}\n\n/**\n * Normalises an event so it always satisfies the protocol limits. Unknown\n * fields are dropped — only the whitelisted metadata below ever leaves the process.\n */\nexport function sanitizeEvent(e: KancaEvent): KancaEvent {\n const out: KancaEvent = {\n id: typeof e.id === \"string\" && e.id ? e.id : uuid(),\n type: e.type,\n ts: isFiniteNumber(e.ts) ? Math.round(e.ts) : Date.now(),\n name: truncate(typeof e.name === \"string\" && e.name ? e.name : \"unknown\", MAX_NAME),\n };\n if (isFiniteNumber(e.status) && e.status >= 100 && e.status <= 599) out.status = Math.trunc(e.status);\n if (typeof e.ok === \"boolean\") out.ok = e.ok;\n if (isFiniteNumber(e.durationMs)) out.durationMs = Math.min(86_400_000, Math.max(0, Math.round(e.durationMs * 10) / 10));\n if (isFiniteNumber(e.value)) out.value = e.value;\n if (e.error && typeof e.error.code === \"string\") {\n out.error = { code: truncate(e.error.code, MAX_ERROR_CODE) };\n if (typeof e.error.message === \"string\" && e.error.message) {\n out.error.message = truncate(e.error.message, MAX_ERROR_MESSAGE);\n }\n }\n if (typeof e.merchantId === \"string\" && e.merchantId) out.merchantId = truncate(e.merchantId, 200);\n if (typeof e.deliveryId === \"string\" && e.deliveryId) out.deliveryId = truncate(e.deliveryId, 200);\n const attrs = cleanAttrs(e.attrs);\n if (attrs) out.attrs = attrs;\n return out;\n}\n\nexport function errorMessage(err: unknown): string | undefined {\n try {\n if (err instanceof Error) return truncate(err.message || err.name, MAX_ERROR_MESSAGE);\n if (typeof err === \"string\") return truncate(err, MAX_ERROR_MESSAGE);\n } catch {\n /* ignore */\n }\n return undefined;\n}\n\nexport function joinUrl(endpoint: string, path: string): string {\n return endpoint.replace(/\\/+$/, \"\") + path;\n}\n\nconst encoder = typeof TextEncoder !== \"undefined\" ? new TextEncoder() : undefined;\nexport function byteLength(s: string): number {\n return encoder ? encoder.encode(s).length : s.length * 3;\n}\n\n/**\n * Splits events into protocol-sized batches (<= maxEvents events and\n * <= MAX_BATCH_BYTES serialized). Returns serialized bodies.\n */\nexport function buildBodies(events: KancaEvent[], maxEvents: number): { body: string; events: KancaEvent[] }[] {\n const sdk = { name: SDK_NAME, version: SDK_VERSION };\n const envelope = byteLength(JSON.stringify({ sdk, events: [] }));\n const out: { body: string; events: KancaEvent[] }[] = [];\n let cur: KancaEvent[] = [];\n let size = envelope;\n const push = () => {\n if (cur.length) out.push({ body: JSON.stringify({ sdk, events: cur } satisfies Batch), events: cur });\n cur = [];\n size = envelope;\n };\n for (const ev of events) {\n const len = byteLength(JSON.stringify(ev)) + 1;\n if (cur.length && (cur.length >= maxEvents || size + len > MAX_BATCH_BYTES)) push();\n cur.push(ev);\n size += len;\n }\n push();\n return out;\n}\n"],"mappings":"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","names":[]}