@zerocost/sdk 0.17.0 → 0.19.0

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.
@@ -0,0 +1,187 @@
1
+ export class RecordingModule {
2
+ client;
3
+ config = null;
4
+ events = [];
5
+ observer = null;
6
+ flushInterval = null;
7
+ stopTimer = null;
8
+ handlers = [];
9
+ sessionId = '';
10
+ startTime = 0;
11
+ constructor(client) {
12
+ this.client = client;
13
+ }
14
+ /**
15
+ * Start a lightweight UX session recording.
16
+ */
17
+ start(config) {
18
+ if (typeof document === 'undefined')
19
+ return;
20
+ this.config = config;
21
+ this.sessionId = this.generateId();
22
+ this.startTime = Date.now();
23
+ // Roll dice on sample rate
24
+ if (Math.random() * 100 > config.sampleRate) {
25
+ this.client.log('Recording: session not sampled, skipping');
26
+ return;
27
+ }
28
+ this.client.log(`Recording: started session ${this.sessionId} (max ${config.maxDuration}s)`);
29
+ // Capture initial viewport state
30
+ this.pushEvent('resize', {
31
+ width: window.innerWidth,
32
+ height: window.innerHeight,
33
+ url: location.href,
34
+ title: document.title,
35
+ });
36
+ // Observe DOM mutations
37
+ this.observer = new MutationObserver((mutations) => {
38
+ for (const m of mutations) {
39
+ if (m.type === 'childList') {
40
+ this.pushEvent('mutation', {
41
+ target: this.describeEl(m.target),
42
+ added: m.addedNodes.length,
43
+ removed: m.removedNodes.length,
44
+ });
45
+ }
46
+ else if (m.type === 'attributes') {
47
+ this.pushEvent('mutation', {
48
+ target: this.describeEl(m.target),
49
+ attr: m.attributeName,
50
+ });
51
+ }
52
+ }
53
+ });
54
+ this.observer.observe(document.body, {
55
+ childList: true,
56
+ attributes: true,
57
+ subtree: true,
58
+ characterData: false,
59
+ });
60
+ // Click tracking
61
+ this.on('click', (e) => {
62
+ const target = e.target;
63
+ this.pushEvent('click', {
64
+ x: e.clientX,
65
+ y: e.clientY,
66
+ target: this.describeEl(target),
67
+ });
68
+ }, true);
69
+ // Scroll tracking (throttled)
70
+ let scrollTimeout = null;
71
+ this.on('scroll', () => {
72
+ if (scrollTimeout)
73
+ return;
74
+ scrollTimeout = setTimeout(() => {
75
+ scrollTimeout = null;
76
+ this.pushEvent('scroll', {
77
+ x: window.scrollX,
78
+ y: window.scrollY,
79
+ });
80
+ }, 250);
81
+ });
82
+ // Input tracking (blurred)
83
+ if (!config.blurInputs) {
84
+ this.on('input', (e) => {
85
+ const target = e.target;
86
+ if (this.shouldMask(target))
87
+ return;
88
+ this.pushEvent('input', {
89
+ target: this.describeEl(target),
90
+ length: target.value?.length || 0,
91
+ // Never send actual values - just metadata
92
+ type: target.type || 'text',
93
+ });
94
+ }, true);
95
+ }
96
+ // Navigation tracking
97
+ this.on('popstate', () => {
98
+ this.pushEvent('navigation', { url: location.pathname });
99
+ });
100
+ // Flush every 15 seconds
101
+ this.flushInterval = setInterval(() => this.flush(), 15_000);
102
+ // Stop after max duration
103
+ this.stopTimer = setTimeout(() => {
104
+ this.client.log('Recording: max duration reached, stopping');
105
+ this.stop();
106
+ }, config.maxDuration * 1000);
107
+ }
108
+ stop() {
109
+ if (this.observer) {
110
+ this.observer.disconnect();
111
+ this.observer = null;
112
+ }
113
+ for (const { event, fn } of this.handlers) {
114
+ (event === 'click' ? document : window).removeEventListener(event, fn, true);
115
+ }
116
+ this.handlers = [];
117
+ if (this.flushInterval)
118
+ clearInterval(this.flushInterval);
119
+ if (this.stopTimer)
120
+ clearTimeout(this.stopTimer);
121
+ this.flush();
122
+ this.config = null;
123
+ }
124
+ // ── Private ──
125
+ on(event, fn, capture = false) {
126
+ const target = event === 'click' ? document : window;
127
+ target.addEventListener(event, fn, capture);
128
+ this.handlers.push({ event, fn });
129
+ }
130
+ pushEvent(type, data) {
131
+ this.events.push({
132
+ type,
133
+ timestamp: Date.now() - this.startTime,
134
+ data,
135
+ });
136
+ if (this.events.length >= 100)
137
+ this.flush();
138
+ }
139
+ async flush() {
140
+ if (this.events.length === 0)
141
+ return;
142
+ const batch = [...this.events];
143
+ this.events = [];
144
+ try {
145
+ await this.client.request('/ingest-data', {
146
+ type: 'recording',
147
+ sessionId: this.sessionId,
148
+ events: batch,
149
+ });
150
+ }
151
+ catch (err) {
152
+ this.client.log(`Recording flush error: ${err}`);
153
+ this.events.unshift(...batch);
154
+ }
155
+ }
156
+ shouldMask(el) {
157
+ if (!this.config)
158
+ return true;
159
+ for (const sel of this.config.maskSelectors) {
160
+ try {
161
+ if (el.matches(sel) || el.closest(sel))
162
+ return true;
163
+ }
164
+ catch { /* invalid selector */ }
165
+ }
166
+ // Always mask password and sensitive fields
167
+ const type = el.type;
168
+ return type === 'password' || type === 'hidden';
169
+ }
170
+ describeEl(el) {
171
+ if (!el || !el.tagName)
172
+ return '';
173
+ let desc = el.tagName.toLowerCase();
174
+ if (el.id)
175
+ desc += `#${el.id}`;
176
+ const role = el.getAttribute('role');
177
+ if (role)
178
+ desc += `[role=${role}]`;
179
+ const label = el.getAttribute('aria-label');
180
+ if (label)
181
+ desc += `[${label.slice(0, 20)}]`;
182
+ return desc;
183
+ }
184
+ generateId() {
185
+ return 'rec_' + Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
186
+ }
187
+ }
@@ -0,0 +1,27 @@
1
+ export class TrackModule {
2
+ client;
3
+ constructor(client) {
4
+ this.client = client;
5
+ }
6
+ /**
7
+ * Track a custom event with optional properties.
8
+ */
9
+ async event(eventName, properties) {
10
+ await this.client.request('/track-event', {
11
+ event_name: eventName,
12
+ properties: properties || {},
13
+ });
14
+ }
15
+ /**
16
+ * Track an ad impression.
17
+ */
18
+ async impression(adId, placementId) {
19
+ await this.event('ad_impression', { ad_id: adId, placement_id: placementId });
20
+ }
21
+ /**
22
+ * Track an ad click.
23
+ */
24
+ async click(adId, placementId) {
25
+ await this.event('ad_click', { ad_id: adId, placement_id: placementId });
26
+ }
27
+ }
@@ -0,0 +1,301 @@
1
+ import { renderWidgetMarkup, SDK_WIDGET_REFRESH_MS } from '../core/widget-render';
2
+ const POSITION_STYLES = {
3
+ 'bottom-right': 'position:fixed;bottom:24px;right:40px;z-index:9999;',
4
+ 'bottom-left': 'position:fixed;bottom:24px;left:80px;z-index:9999;',
5
+ 'top-right': 'position:fixed;top:24px;right:40px;z-index:9999;',
6
+ 'top-left': 'position:fixed;top:24px;left:24px;z-index:9999;',
7
+ 'bottom-center': 'position:fixed;bottom:24px;left:50%;transform:translateX(-50%);z-index:9999;',
8
+ 'top-center': 'position:fixed;top:24px;left:50%;transform:translateX(-50%);z-index:9999;',
9
+ 'center': 'position:fixed;top:50%;left:50%;transform:translate(-50%,-50%);z-index:9999;',
10
+ 'sidebar-left': 'position:fixed;top:50%;left:24px;transform:translateY(-50%);z-index:9999;',
11
+ 'sidebar-right': 'position:fixed;top:50%;right:40px;transform:translateY(-50%);z-index:9999;',
12
+ };
13
+ const FORMAT_PRIORITY = ['video-widget', 'tooltip-ad', 'sponsored-card', 'sidebar-display', 'inline-text'];
14
+ const AUTO_SLOT_ID = 'zerocost-auto-slot';
15
+ const CHAT_CONTAINER_SELECTORS = [
16
+ '[data-zerocost-chat]',
17
+ '[data-zc-chat]',
18
+ '[data-chat]',
19
+ '[data-chat-stream]',
20
+ '[data-conversation]',
21
+ '[data-ai-chat]',
22
+ '[role="log"]',
23
+ '[aria-label*="chat" i]',
24
+ '[aria-label*="conversation" i]',
25
+ ];
26
+ export class WidgetModule {
27
+ client;
28
+ mounted = new Map();
29
+ constructor(client) {
30
+ this.client = client;
31
+ }
32
+ async autoInjectWithConfig(display, widget) {
33
+ try {
34
+ const selected = this.resolveSelectedWidgets(display, widget);
35
+ this.clearAutoInjectedSlots();
36
+ if (selected.length === 0) {
37
+ this.client.log('No enabled widget format found. Skipping injection.');
38
+ return;
39
+ }
40
+ for (const config of selected) {
41
+ this.client.log(`Auto-inject: rendering configured format "${config.format}".`);
42
+ await this.mountSingleFormat(config);
43
+ }
44
+ this.client.log('Auto-inject completed.');
45
+ }
46
+ catch (err) {
47
+ this.client.log(`Widget autoInject error: ${err}`);
48
+ }
49
+ }
50
+ async autoInject() {
51
+ try {
52
+ this.client.log('Fetching ad placements from server...');
53
+ const { display, widget } = await this.client.request('/get-placements');
54
+ await this.autoInjectWithConfig(display, widget);
55
+ }
56
+ catch (err) {
57
+ this.client.log(`Widget autoInject error: ${err}`);
58
+ }
59
+ }
60
+ resolveSelectedWidgets(display, widget) {
61
+ const configs = this.normalizeConfigs(display);
62
+ const selected = [];
63
+ if (widget?.enabled && widget?.format && widget.format !== 'inline-text') {
64
+ selected.push({
65
+ format: widget.format,
66
+ position: widget.position || 'bottom-right',
67
+ theme: widget.theme || 'dark',
68
+ autoplay: widget.autoplay ?? widget.format === 'video-widget',
69
+ enabled: true,
70
+ });
71
+ }
72
+ else {
73
+ for (const format of FORMAT_PRIORITY) {
74
+ if (format === 'inline-text')
75
+ continue;
76
+ const config = configs[format];
77
+ if (config?.enabled) {
78
+ selected.push({
79
+ format,
80
+ position: config.position,
81
+ theme: config.theme,
82
+ autoplay: config.autoplay,
83
+ enabled: true,
84
+ });
85
+ break;
86
+ }
87
+ }
88
+ }
89
+ const inlineConfig = configs['inline-text'];
90
+ if (widget?.enabled && widget?.format === 'inline-text') {
91
+ selected.push({
92
+ format: 'inline-text',
93
+ position: widget.position || inlineConfig?.position || 'after-paragraph-1',
94
+ theme: widget.theme || inlineConfig?.theme || 'dark',
95
+ autoplay: false,
96
+ enabled: true,
97
+ });
98
+ }
99
+ else if (inlineConfig?.enabled) {
100
+ selected.push({
101
+ format: 'inline-text',
102
+ position: inlineConfig.position,
103
+ theme: inlineConfig.theme,
104
+ autoplay: false,
105
+ enabled: true,
106
+ });
107
+ }
108
+ return selected;
109
+ }
110
+ normalizeConfigs(display) {
111
+ if (display && typeof display === 'object' && ('video-widget' in display || 'floating-video' in display)) {
112
+ const source = display;
113
+ const videoConfig = source['video-widget'] || source['floating-video'];
114
+ const sponsoredCardConfig = source['sponsored-card'] || source['sidebar-display'];
115
+ return {
116
+ 'video-widget': videoConfig || { position: 'bottom-right', theme: 'dark', autoplay: true, enabled: true },
117
+ 'tooltip-ad': source['tooltip-ad'] || { position: 'bottom-right', theme: 'dark', autoplay: false, enabled: false },
118
+ 'sponsored-card': sponsoredCardConfig || { position: 'bottom-right', theme: 'dark', autoplay: false, enabled: false },
119
+ 'sidebar-display': sponsoredCardConfig || { position: 'bottom-right', theme: 'dark', autoplay: false, enabled: false },
120
+ 'inline-text': source['inline-text'] || { position: 'after-paragraph-1', theme: 'dark', autoplay: false, enabled: false },
121
+ };
122
+ }
123
+ const pos = display?.position || 'bottom-right';
124
+ const theme = display?.theme || 'dark';
125
+ return {
126
+ 'video-widget': { position: pos, theme, autoplay: true, enabled: true },
127
+ 'tooltip-ad': { position: pos, theme, autoplay: false, enabled: false },
128
+ 'sponsored-card': { position: pos, theme, autoplay: false, enabled: false },
129
+ 'sidebar-display': { position: pos, theme, autoplay: false, enabled: false },
130
+ 'inline-text': { position: 'after-paragraph-1', theme, autoplay: false, enabled: false },
131
+ };
132
+ }
133
+ async mountSingleFormat(config) {
134
+ const isInline = config.format === 'inline-text';
135
+ const targetElementId = isInline ? this.ensureInlineTarget(config.position) : AUTO_SLOT_ID;
136
+ if (!targetElementId) {
137
+ this.client.log('Inline target not found. Skipping inline ad render.');
138
+ return;
139
+ }
140
+ if (!isInline) {
141
+ let element = document.getElementById(AUTO_SLOT_ID);
142
+ if (!element) {
143
+ element = document.createElement('div');
144
+ element.id = AUTO_SLOT_ID;
145
+ document.body.appendChild(element);
146
+ }
147
+ const posStyle = POSITION_STYLES[config.position] || POSITION_STYLES['bottom-right'];
148
+ const maxW = config.format === 'video-widget' ? 'max-width:200px;' : 'max-width:176px;';
149
+ element.setAttribute('style', `${posStyle}${maxW}`);
150
+ element.setAttribute('data-zerocost', '');
151
+ element.setAttribute('data-format', config.format);
152
+ }
153
+ await this.mount(targetElementId, {
154
+ format: config.format,
155
+ refreshInterval: SDK_WIDGET_REFRESH_MS / 1000,
156
+ theme: config.theme,
157
+ autoplay: config.autoplay,
158
+ position: config.position,
159
+ });
160
+ }
161
+ ensureInlineTarget(position) {
162
+ const chatContainer = this.findChatContainer();
163
+ if (!chatContainer)
164
+ return null;
165
+ const paragraphMatch = /after-paragraph-(\d+)/.exec(position || '');
166
+ const index = paragraphMatch ? Number(paragraphMatch[1]) : 1;
167
+ const paragraphs = Array.from(chatContainer.querySelectorAll('p'));
168
+ const anchor = paragraphs[Math.max(0, Math.min(paragraphs.length - 1, index - 1))];
169
+ if (!anchor)
170
+ return null;
171
+ const existing = document.getElementById(AUTO_SLOT_ID);
172
+ if (existing)
173
+ existing.remove();
174
+ const inlineId = `${AUTO_SLOT_ID}-inline`;
175
+ let target = document.getElementById(inlineId);
176
+ if (!target) {
177
+ target = document.createElement('div');
178
+ target.id = inlineId;
179
+ target.setAttribute('data-zerocost', '');
180
+ target.setAttribute('data-format', 'inline-text');
181
+ target.style.margin = '12px 0';
182
+ anchor.insertAdjacentElement('afterend', target);
183
+ }
184
+ return inlineId;
185
+ }
186
+ findChatContainer() {
187
+ for (const selector of CHAT_CONTAINER_SELECTORS) {
188
+ const container = document.querySelector(selector);
189
+ if (container)
190
+ return container;
191
+ }
192
+ const semanticContainers = Array.from(document.querySelectorAll('section, main, div, article'));
193
+ return semanticContainers.find((node) => {
194
+ const marker = `${node.id} ${node.className || ''}`.toLowerCase();
195
+ return /chat|conversation|assistant|messages|thread/.test(marker);
196
+ }) || null;
197
+ }
198
+ async mount(targetElementId, options = {}) {
199
+ const element = document.getElementById(targetElementId);
200
+ if (!element)
201
+ return;
202
+ if (this.mounted.has(targetElementId))
203
+ this.unmount(targetElementId);
204
+ const refreshMs = (options.refreshInterval ?? (SDK_WIDGET_REFRESH_MS / 1000)) * 1000;
205
+ const theme = options.theme || 'dark';
206
+ const format = options.format || 'video-widget';
207
+ const autoplay = options.autoplay ?? format === 'video-widget';
208
+ const render = async () => {
209
+ try {
210
+ const body = {
211
+ widget_style: format,
212
+ theme,
213
+ autoplay,
214
+ position: options.position,
215
+ };
216
+ const data = await this.client.request('/serve-widget', body);
217
+ const ad = data.ad;
218
+ if (!ad) {
219
+ this.client.log(`No ad inventory available for configured format "${format}".`);
220
+ element.innerHTML = '';
221
+ return;
222
+ }
223
+ element.innerHTML = renderWidgetMarkup(ad, { format, theme });
224
+ element.setAttribute('data-zerocost-ad-id', ad.id);
225
+ this.ensureVideoPlayback(element);
226
+ // Track Impression (Client-side with Viewport checking)
227
+ const observer = new IntersectionObserver((entries) => {
228
+ entries.forEach((entry) => {
229
+ if (entry.isIntersecting) {
230
+ this.client.log(`Ad visible: tracking impression for ${ad.id}`);
231
+ this.client.request('/track-event', {
232
+ event_name: 'ad_impression',
233
+ properties: { ad_id: ad.id, placement_id: options.position || 'auto' },
234
+ }).catch(() => { });
235
+ observer.disconnect(); // Track once per render
236
+ }
237
+ });
238
+ }, { threshold: 0.2 }); // 20% visible counts as impression
239
+ observer.observe(element);
240
+ const ctas = element.querySelectorAll('[data-zc-cta]');
241
+ ctas.forEach((cta) => {
242
+ cta.addEventListener('click', () => {
243
+ this.client.request('/track-event', {
244
+ event_name: 'ad_click',
245
+ properties: { ad_id: ad.id, format },
246
+ }).catch(() => { });
247
+ });
248
+ });
249
+ const closeBtn = element.querySelector('[data-zc-close]');
250
+ if (closeBtn) {
251
+ closeBtn.addEventListener('click', (event) => {
252
+ event.preventDefault();
253
+ event.stopPropagation();
254
+ this.unmount(targetElementId);
255
+ });
256
+ }
257
+ }
258
+ catch (err) {
259
+ this.client.log(`Widget render error: ${err}`);
260
+ }
261
+ };
262
+ await render();
263
+ const interval = refreshMs > 0 ? setInterval(render, refreshMs) : null;
264
+ this.mounted.set(targetElementId, { elementId: targetElementId, interval });
265
+ }
266
+ ensureVideoPlayback(root) {
267
+ const video = root.querySelector('video');
268
+ if (!video)
269
+ return;
270
+ video.muted = true;
271
+ video.autoplay = true;
272
+ video.loop = true;
273
+ video.playsInline = true;
274
+ video.preload = 'auto';
275
+ const tryPlay = () => video.play().catch(() => { });
276
+ if (video.readyState >= 2) {
277
+ tryPlay();
278
+ return;
279
+ }
280
+ video.addEventListener('loadeddata', tryPlay, { once: true });
281
+ video.addEventListener('canplay', tryPlay, { once: true });
282
+ }
283
+ clearAutoInjectedSlots() {
284
+ this.unmountAll();
285
+ const existing = document.querySelectorAll('[data-zerocost], #zerocost-auto-slot, #zerocost-auto-slot-inline');
286
+ existing.forEach((node) => node.remove());
287
+ }
288
+ unmount(targetElementId) {
289
+ const slot = this.mounted.get(targetElementId);
290
+ if (slot?.interval)
291
+ clearInterval(slot.interval);
292
+ const element = document.getElementById(targetElementId);
293
+ if (element)
294
+ element.remove();
295
+ this.mounted.delete(targetElementId);
296
+ }
297
+ unmountAll() {
298
+ for (const id of Array.from(this.mounted.keys()))
299
+ this.unmount(id);
300
+ }
301
+ }
@@ -0,0 +1,2 @@
1
+ // ── Consent ──────────────────────────────────────────────────────────
2
+ export {};
package/package.json CHANGED
@@ -1,13 +1,15 @@
1
1
  {
2
2
  "name": "@zerocost/sdk",
3
- "version": "0.17.0",
3
+ "version": "0.19.0",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",
7
7
  "types": "dist/index.d.ts",
8
- "files": ["dist"],
8
+ "files": [
9
+ "dist"
10
+ ],
9
11
  "scripts": {
10
- "build": "tsup src/index.ts --format cjs,esm --tsconfig ./tsconfig.json && tsc -p tsconfig.json --emitDeclarationOnly --declaration --outDir dist",
12
+ "build": "tsup src/index.ts --format cjs,esm --tsconfig ./tsconfig.json && npx tsc -p tsconfig.json --emitDeclarationOnly --declaration --outDir dist",
11
13
  "prepublishOnly": "npm run build"
12
14
  },
13
15
  "devDependencies": {
@@ -18,7 +20,13 @@
18
20
  "license": "MIT",
19
21
  "repository": {
20
22
  "type": "git",
21
- "url": "https://github.com/harshil12345000/zerocost"
23
+ "url": "git+https://github.com/harshil12345000/zerocost.git"
22
24
  },
23
- "keywords": ["zerocost", "ai", "monetization", "sdk", "ads"]
25
+ "keywords": [
26
+ "zerocost",
27
+ "ai",
28
+ "monetization",
29
+ "sdk",
30
+ "ads"
31
+ ]
24
32
  }