@klime/browser 1.0.3

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 ADDED
@@ -0,0 +1,166 @@
1
+ # @klime/browser
2
+
3
+ Klime SDK for browsers.
4
+
5
+ ## Installation
6
+
7
+ ```bash
8
+ npm install @klime/browser
9
+ ```
10
+
11
+ ## Quick Start
12
+
13
+ ```javascript
14
+ import { KlimeClient } from "@klime/browser";
15
+
16
+ const client = new KlimeClient({
17
+ writeKey: "your-write-key",
18
+ });
19
+
20
+ // Identify a user
21
+ client.identify("user_123", {
22
+ email: "user@example.com",
23
+ name: "Stefan",
24
+ });
25
+
26
+ // Track an event
27
+ client.track(
28
+ "Button Clicked",
29
+ {
30
+ buttonName: "Sign up",
31
+ plan: "pro",
32
+ },
33
+ { userId: "user_123" }
34
+ );
35
+
36
+ // Associate user with a group and set group traits
37
+ client.group(
38
+ "org_456",
39
+ { name: "Acme Inc", plan: "enterprise" },
40
+ { userId: "user_123" }
41
+ );
42
+
43
+ // Or just link the user to a group (if traits are already set)
44
+ client.group("org_456", null, { userId: "user_123" });
45
+ ```
46
+
47
+ ## API Reference
48
+
49
+ ### Constructor
50
+
51
+ ```typescript
52
+ new KlimeClient(config: {
53
+ writeKey: string; // Required: Your Klime write key
54
+ endpoint?: string; // Optional: API endpoint (default: https://i.klime.com)
55
+ flushInterval?: number; // Optional: Milliseconds between flushes (default: 2000)
56
+ maxBatchSize?: number; // Optional: Max events per batch (default: 20, max: 100)
57
+ maxQueueSize?: number; // Optional: Max queued events (default: 1000)
58
+ retryMaxAttempts?: number; // Optional: Max retry attempts (default: 5)
59
+ retryInitialDelay?: number; // Optional: Initial retry delay in ms (default: 1000)
60
+ autoFlushOnUnload?: boolean; // Optional: Auto-flush on page unload (default: true)
61
+ })
62
+ ```
63
+
64
+ ### Methods
65
+
66
+ #### `track(event: string, properties?: object, options?: { userId?, groupId? })`
67
+
68
+ Track a user event. A `userId` is required for events to be useful in Klime.
69
+
70
+ ```javascript
71
+ client.track(
72
+ "Button Clicked",
73
+ {
74
+ buttonName: "Sign up",
75
+ plan: "pro",
76
+ },
77
+ { userId: "user_123" }
78
+ );
79
+ ```
80
+
81
+ > **Advanced**: The `groupId` option is available for multi-tenant scenarios where a user belongs to multiple organizations and you need to specify which organization context the event occurred in.
82
+
83
+ #### `identify(userId: string, traits?: object)`
84
+
85
+ Identify a user with traits.
86
+
87
+ ```javascript
88
+ client.identify("user_123", {
89
+ email: "user@example.com",
90
+ name: "Stefan",
91
+ });
92
+ ```
93
+
94
+ #### `group(groupId: string, traits?: object, options?: { userId? })`
95
+
96
+ Associate a user with a group and/or set group traits.
97
+
98
+ ```javascript
99
+ // Associate user with a group and set group traits (most common)
100
+ client.group(
101
+ "org_456",
102
+ { name: "Acme Inc", plan: "enterprise" },
103
+ { userId: "user_123" }
104
+ );
105
+
106
+ // Just link a user to a group (traits already set or not needed)
107
+ client.group("org_456", null, { userId: "user_123" });
108
+
109
+ // Just update group traits (e.g., from a webhook or background job)
110
+ client.group("org_456", { plan: "enterprise", employeeCount: 50 });
111
+ ```
112
+
113
+ #### `flush(): Promise<void>`
114
+
115
+ Manually flush queued events immediately.
116
+
117
+ ```javascript
118
+ await client.flush();
119
+ ```
120
+
121
+ #### `shutdown(): Promise<void>`
122
+
123
+ Gracefully shutdown the client, flushing remaining events.
124
+
125
+ ```javascript
126
+ await client.shutdown();
127
+ ```
128
+
129
+ ## Features
130
+
131
+ - **Automatic Batching**: Events are automatically batched and sent every 2 seconds or when the batch size reaches 20 events
132
+ - **Automatic Retries**: Failed requests are automatically retried with exponential backoff
133
+ - **Browser Context**: Automatically captures userAgent, locale, and timezone
134
+ - **Page Unload Handling**: Automatically flushes events when the page is about to unload
135
+ - **Zero Dependencies**: Uses only native browser APIs
136
+
137
+ ## Configuration
138
+
139
+ ### Default Values
140
+
141
+ - `flushInterval`: 2000ms
142
+ - `maxBatchSize`: 20 events
143
+ - `maxQueueSize`: 1000 events
144
+ - `retryMaxAttempts`: 5 attempts
145
+ - `retryInitialDelay`: 1000ms
146
+ - `autoFlushOnUnload`: true
147
+
148
+ ## Error Handling
149
+
150
+ The SDK automatically handles:
151
+
152
+ - **Transient errors** (429, 503, network failures): Retries with exponential backoff
153
+ - **Permanent errors** (400, 401): Logs error and drops event
154
+ - **Rate limiting**: Respects `Retry-After` header
155
+
156
+ ## Size Limits
157
+
158
+ - Maximum event size: 200KB
159
+ - Maximum batch size: 10MB
160
+ - Maximum events per batch: 100
161
+
162
+ Events exceeding these limits are rejected and logged.
163
+
164
+ ## License
165
+
166
+ MIT
@@ -0,0 +1,25 @@
1
+ import { KlimeConfig, TrackOptions } from "./types";
2
+ export declare class KlimeClient {
3
+ private config;
4
+ private queue;
5
+ private flushTimer;
6
+ private isShutdown;
7
+ private flushPromise;
8
+ private unloadHandler;
9
+ constructor(config: KlimeConfig);
10
+ track(event: string, properties?: Record<string, any>, options?: TrackOptions): void;
11
+ identify(userId: string, traits?: Record<string, any>): void;
12
+ group(groupId: string, traits?: Record<string, any>, options?: TrackOptions): void;
13
+ flush(): Promise<void>;
14
+ shutdown(): Promise<void>;
15
+ private enqueue;
16
+ private doFlush;
17
+ private extractBatch;
18
+ private sendBatch;
19
+ private scheduleFlush;
20
+ private generateUUID;
21
+ private generateTimestamp;
22
+ private getContext;
23
+ private estimateEventSize;
24
+ private sleep;
25
+ }
package/dist/index.js ADDED
@@ -0,0 +1,296 @@
1
+ const DEFAULT_ENDPOINT = "https://i.klime.com";
2
+ const DEFAULT_FLUSH_INTERVAL = 2000;
3
+ const DEFAULT_MAX_BATCH_SIZE = 20;
4
+ const DEFAULT_MAX_QUEUE_SIZE = 1000;
5
+ const DEFAULT_RETRY_MAX_ATTEMPTS = 5;
6
+ const DEFAULT_RETRY_INITIAL_DELAY = 1000;
7
+ const MAX_BATCH_SIZE = 100;
8
+ const MAX_EVENT_SIZE_BYTES = 200 * 1024; // 200KB
9
+ const MAX_BATCH_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
10
+ const SDK_VERSION = "1.0.1";
11
+ export class KlimeClient {
12
+ constructor(config) {
13
+ this.queue = [];
14
+ this.flushTimer = null;
15
+ this.isShutdown = false;
16
+ this.flushPromise = null;
17
+ this.unloadHandler = null;
18
+ if (!config.writeKey) {
19
+ throw new Error("writeKey is required");
20
+ }
21
+ this.config = {
22
+ writeKey: config.writeKey,
23
+ endpoint: config.endpoint || DEFAULT_ENDPOINT,
24
+ flushInterval: config.flushInterval ?? DEFAULT_FLUSH_INTERVAL,
25
+ maxBatchSize: Math.min(config.maxBatchSize ?? DEFAULT_MAX_BATCH_SIZE, MAX_BATCH_SIZE),
26
+ maxQueueSize: config.maxQueueSize ?? DEFAULT_MAX_QUEUE_SIZE,
27
+ retryMaxAttempts: config.retryMaxAttempts ?? DEFAULT_RETRY_MAX_ATTEMPTS,
28
+ retryInitialDelay: config.retryInitialDelay ?? DEFAULT_RETRY_INITIAL_DELAY,
29
+ autoFlushOnUnload: config.autoFlushOnUnload ?? true,
30
+ };
31
+ if (this.config.autoFlushOnUnload && typeof window !== "undefined") {
32
+ this.unloadHandler = () => {
33
+ this.flush();
34
+ };
35
+ window.addEventListener("beforeunload", this.unloadHandler);
36
+ }
37
+ this.scheduleFlush();
38
+ }
39
+ track(event, properties, options) {
40
+ if (this.isShutdown) {
41
+ return;
42
+ }
43
+ const eventObj = {
44
+ type: "track",
45
+ messageId: this.generateUUID(),
46
+ event,
47
+ timestamp: this.generateTimestamp(),
48
+ properties: properties || {},
49
+ context: this.getContext(),
50
+ };
51
+ if (options?.userId) {
52
+ eventObj.userId = options.userId;
53
+ }
54
+ if (options?.groupId) {
55
+ eventObj.groupId = options.groupId;
56
+ }
57
+ this.enqueue(eventObj);
58
+ }
59
+ identify(userId, traits) {
60
+ if (this.isShutdown) {
61
+ return;
62
+ }
63
+ const eventObj = {
64
+ type: "identify",
65
+ messageId: this.generateUUID(),
66
+ userId,
67
+ timestamp: this.generateTimestamp(),
68
+ traits: traits || {},
69
+ context: this.getContext(),
70
+ };
71
+ this.enqueue(eventObj);
72
+ }
73
+ group(groupId, traits, options) {
74
+ if (this.isShutdown) {
75
+ return;
76
+ }
77
+ const eventObj = {
78
+ type: "group",
79
+ messageId: this.generateUUID(),
80
+ groupId,
81
+ timestamp: this.generateTimestamp(),
82
+ traits: traits || {},
83
+ context: this.getContext(),
84
+ };
85
+ if (options?.userId) {
86
+ eventObj.userId = options.userId;
87
+ }
88
+ this.enqueue(eventObj);
89
+ }
90
+ async flush() {
91
+ if (this.flushPromise) {
92
+ return this.flushPromise;
93
+ }
94
+ this.flushPromise = this.doFlush();
95
+ try {
96
+ await this.flushPromise;
97
+ }
98
+ finally {
99
+ this.flushPromise = null;
100
+ }
101
+ }
102
+ async shutdown() {
103
+ if (this.isShutdown) {
104
+ return;
105
+ }
106
+ this.isShutdown = true;
107
+ if (this.unloadHandler && typeof window !== "undefined") {
108
+ window.removeEventListener("beforeunload", this.unloadHandler);
109
+ }
110
+ if (this.flushTimer) {
111
+ clearTimeout(this.flushTimer);
112
+ this.flushTimer = null;
113
+ }
114
+ await this.flush();
115
+ }
116
+ enqueue(event) {
117
+ // Check event size
118
+ const eventSize = this.estimateEventSize(event);
119
+ if (eventSize > MAX_EVENT_SIZE_BYTES) {
120
+ console.error(`Klime: Event size (${eventSize} bytes) exceeds ${MAX_EVENT_SIZE_BYTES} bytes limit`);
121
+ return;
122
+ }
123
+ // Drop oldest if queue is full
124
+ if (this.queue.length >= this.config.maxQueueSize) {
125
+ this.queue.shift();
126
+ }
127
+ this.queue.push(event);
128
+ // Check if we should flush immediately
129
+ if (this.queue.length >= this.config.maxBatchSize) {
130
+ this.flush();
131
+ }
132
+ }
133
+ async doFlush() {
134
+ if (this.queue.length === 0) {
135
+ return;
136
+ }
137
+ // Clear the flush timer
138
+ if (this.flushTimer) {
139
+ clearTimeout(this.flushTimer);
140
+ this.flushTimer = null;
141
+ }
142
+ // Process batches
143
+ while (this.queue.length > 0) {
144
+ const batch = this.extractBatch();
145
+ if (batch.length === 0) {
146
+ break;
147
+ }
148
+ await this.sendBatch(batch);
149
+ }
150
+ // Schedule next flush
151
+ this.scheduleFlush();
152
+ }
153
+ extractBatch() {
154
+ const batch = [];
155
+ let batchSize = 0;
156
+ while (this.queue.length > 0 && batch.length < MAX_BATCH_SIZE) {
157
+ const event = this.queue[0];
158
+ const eventSize = this.estimateEventSize(event);
159
+ // Check if adding this event would exceed batch size limit
160
+ if (batchSize + eventSize > MAX_BATCH_SIZE_BYTES) {
161
+ break;
162
+ }
163
+ batch.push(this.queue.shift());
164
+ batchSize += eventSize;
165
+ }
166
+ return batch;
167
+ }
168
+ async sendBatch(batch) {
169
+ if (batch.length === 0) {
170
+ return;
171
+ }
172
+ const request = { batch };
173
+ const url = `${this.config.endpoint}/v1/batch`;
174
+ let attempt = 0;
175
+ let delay = this.config.retryInitialDelay;
176
+ while (attempt < this.config.retryMaxAttempts) {
177
+ try {
178
+ const response = await fetch(url, {
179
+ method: "POST",
180
+ headers: {
181
+ "Content-Type": "application/json",
182
+ Authorization: `Bearer ${this.config.writeKey}`,
183
+ },
184
+ body: JSON.stringify(request),
185
+ });
186
+ const data = await response.json();
187
+ if (response.ok) {
188
+ // Success - check for partial failures
189
+ if (data.failed > 0 && data.errors) {
190
+ console.warn(`Klime: Batch partially failed. Accepted: ${data.accepted}, Failed: ${data.failed}`, data.errors);
191
+ }
192
+ return;
193
+ }
194
+ // Handle error responses
195
+ if (response.status === 400 || response.status === 401) {
196
+ // Permanent errors - don't retry
197
+ console.error(`Klime: Permanent error (${response.status}):`, data);
198
+ return;
199
+ }
200
+ // Transient errors - retry with backoff
201
+ if (response.status === 429 || response.status === 503) {
202
+ const retryAfter = response.headers.get("Retry-After");
203
+ if (retryAfter) {
204
+ delay = parseInt(retryAfter, 10) * 1000;
205
+ }
206
+ attempt++;
207
+ if (attempt < this.config.retryMaxAttempts) {
208
+ await this.sleep(delay);
209
+ delay = Math.min(delay * 2, 16000); // Cap at 16s
210
+ continue;
211
+ }
212
+ }
213
+ // Other errors - retry
214
+ attempt++;
215
+ if (attempt < this.config.retryMaxAttempts) {
216
+ await this.sleep(delay);
217
+ delay = Math.min(delay * 2, 16000);
218
+ }
219
+ }
220
+ catch (error) {
221
+ // Network errors - retry
222
+ attempt++;
223
+ if (attempt < this.config.retryMaxAttempts) {
224
+ await this.sleep(delay);
225
+ delay = Math.min(delay * 2, 16000);
226
+ }
227
+ else {
228
+ console.error("Klime: Failed to send batch after retries:", error);
229
+ }
230
+ }
231
+ }
232
+ }
233
+ scheduleFlush() {
234
+ if (this.isShutdown || this.flushTimer) {
235
+ return;
236
+ }
237
+ this.flushTimer = setTimeout(() => {
238
+ this.flush();
239
+ }, this.config.flushInterval);
240
+ }
241
+ generateUUID() {
242
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
243
+ return crypto.randomUUID();
244
+ }
245
+ // Fallback for older browsers
246
+ return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, (c) => {
247
+ const r = (Math.random() * 16) | 0;
248
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
249
+ return v.toString(16);
250
+ });
251
+ }
252
+ generateTimestamp() {
253
+ return new Date().toISOString();
254
+ }
255
+ getContext() {
256
+ const context = {
257
+ library: {
258
+ name: "js-sdk",
259
+ version: SDK_VERSION,
260
+ },
261
+ };
262
+ if (typeof navigator !== "undefined") {
263
+ if (navigator.userAgent) {
264
+ context.userAgent = navigator.userAgent;
265
+ }
266
+ if (navigator.language) {
267
+ context.locale = navigator.language;
268
+ }
269
+ }
270
+ if (typeof Intl !== "undefined" && Intl.DateTimeFormat) {
271
+ try {
272
+ const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone;
273
+ if (timezone) {
274
+ context.timezone = timezone;
275
+ }
276
+ }
277
+ catch (e) {
278
+ // Ignore timezone errors
279
+ }
280
+ }
281
+ return context;
282
+ }
283
+ estimateEventSize(event) {
284
+ // Rough estimate: JSON stringified size
285
+ try {
286
+ return JSON.stringify(event).length;
287
+ }
288
+ catch {
289
+ // Fallback: rough estimate based on structure
290
+ return 500; // Conservative estimate
291
+ }
292
+ }
293
+ sleep(ms) {
294
+ return new Promise((resolve) => setTimeout(resolve, ms));
295
+ }
296
+ }
@@ -0,0 +1,49 @@
1
+ export interface KlimeConfig {
2
+ writeKey: string;
3
+ endpoint?: string;
4
+ flushInterval?: number;
5
+ maxBatchSize?: number;
6
+ maxQueueSize?: number;
7
+ retryMaxAttempts?: number;
8
+ retryInitialDelay?: number;
9
+ autoFlushOnUnload?: boolean;
10
+ }
11
+ export interface TrackOptions {
12
+ userId?: string;
13
+ groupId?: string;
14
+ }
15
+ export interface Event {
16
+ type: 'track' | 'identify' | 'group';
17
+ messageId: string;
18
+ event?: string;
19
+ userId?: string;
20
+ groupId?: string;
21
+ timestamp: string;
22
+ properties?: Record<string, any>;
23
+ traits?: Record<string, any>;
24
+ context?: EventContext;
25
+ }
26
+ export interface EventContext {
27
+ library?: {
28
+ name: string;
29
+ version: string;
30
+ };
31
+ userAgent?: string;
32
+ locale?: string;
33
+ timezone?: string;
34
+ ip?: string;
35
+ }
36
+ export interface BatchRequest {
37
+ batch: Event[];
38
+ }
39
+ export interface BatchResponse {
40
+ status: string;
41
+ accepted: number;
42
+ failed: number;
43
+ errors?: ValidationError[];
44
+ }
45
+ export interface ValidationError {
46
+ index: number;
47
+ message: string;
48
+ code: string;
49
+ }
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,38 @@
1
+ {
2
+ "name": "@klime/browser",
3
+ "version": "1.0.3",
4
+ "description": "Klime SDK for browsers",
5
+ "main": "dist/index.js",
6
+ "module": "dist/index.js",
7
+ "types": "dist/index.d.ts",
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "clean": "rm -rf dist",
11
+ "test": "vitest run",
12
+ "test:watch": "vitest",
13
+ "prepublishOnly": "npm run build"
14
+ },
15
+ "keywords": [
16
+ "analytics",
17
+ "klime",
18
+ "tracking",
19
+ "events",
20
+ "browser"
21
+ ],
22
+ "author": "Klime",
23
+ "license": "MIT",
24
+ "devDependencies": {
25
+ "jsdom": "^25.0.0",
26
+ "typescript": "^5.0.0",
27
+ "vitest": "^2.0.0"
28
+ },
29
+ "files": [
30
+ "dist"
31
+ ],
32
+ "repository": {
33
+ "type": "git",
34
+ "url": "https://github.com/klimeapp/klime-js.git",
35
+ "directory": "packages/browser"
36
+ },
37
+ "homepage": "https://github.com/klimeapp/klime-js/tree/main/packages/browser#readme"
38
+ }