@g2crowd/buyer-intent-provider-sdk 0.4.0 → 0.5.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.
package/README.md CHANGED
@@ -270,21 +270,64 @@ PARTNER_ID=partner-a KAFKA_BROKERS=broker1:9092,broker2:9092 \
270
270
  buyer-intent-server
271
271
  ```
272
272
 
273
- Environment variables: `PORT` (default 3000), `PARTNER_ID`, `KAFKA_BROKERS` (comma-separated), `TOPIC_PREFIX`, `KAFKA_TOPIC`, `KAFKA_CLIENT_ID`, `USE_DEV_LOGGER=true`.
273
+ Environment variables: `PORT` (default 3000), `PARTNER_ID`, `KAFKA_BROKERS` (comma-separated), `KAFKA_BRIDGE_URL` (Strimzi HTTP bridge), `KAFKA_BRIDGE_USERNAME`, `KAFKA_BRIDGE_PASSWORD`, `TOPIC_PREFIX`, `KAFKA_TOPIC`, `KAFKA_CLIENT_ID`, `USE_DEV_LOGGER=true`.
274
274
 
275
275
  ### Custom Kafka Producer
276
276
 
277
277
  ```ts
278
- import { Kafka } from 'kafkajs';
278
+ import {
279
+ createNextRouteHandler,
280
+ createKafkaProducer,
281
+ } from '@g2crowd/buyer-intent-provider-sdk/server';
282
+
283
+ const POST = createNextRouteHandler({
284
+ producer: createKafkaProducer({
285
+ brokers: (process.env.KAFKA_BROKERS || '').split(','),
286
+ clientId: 'partner-app',
287
+ }),
288
+ partnerId: process.env.PARTNER_ID,
289
+ });
290
+
291
+ export { POST };
292
+ ```
293
+
294
+ You can also pass a raw `kafkajs` producer or any object with a `send({ topic, messages })` method.
295
+
296
+ ### HTTP Bridge (Strimzi)
297
+
298
+ Use `KAFKA_BRIDGE_URL` to produce via an HTTP bridge instead of connecting to Kafka directly. No `kafkajs` dependency required.
299
+
300
+ ```bash
301
+ PARTNER_ID=partner-a KAFKA_BRIDGE_URL=http://my-bridge:8080 \
302
+ buyer-intent-server
303
+ ```
304
+
305
+ Or pass it programmatically:
306
+
307
+ ```ts
279
308
  import { createNextRouteHandler } from '@g2crowd/buyer-intent-provider-sdk/server';
280
309
 
281
- const kafka = new Kafka({
282
- clientId: 'partner-app',
283
- brokers: (process.env.KAFKA_BROKERS || '').split(','),
310
+ const POST = createNextRouteHandler({
311
+ kafkaBridgeUrl: process.env.KAFKA_BRIDGE_URL,
312
+ partnerId: process.env.PARTNER_ID,
284
313
  });
285
314
 
315
+ export { POST };
316
+ ```
317
+
318
+ For custom headers (e.g. auth tokens):
319
+
320
+ ```ts
321
+ import {
322
+ createNextRouteHandler,
323
+ createHttpProducer,
324
+ } from '@g2crowd/buyer-intent-provider-sdk/server';
325
+
286
326
  const POST = createNextRouteHandler({
287
- producer: kafka.producer({ allowAutoTopicCreation: false }),
327
+ producer: createHttpProducer({
328
+ bridgeUrl: process.env.KAFKA_BRIDGE_URL,
329
+ headers: { Authorization: `Bearer ${process.env.BRIDGE_TOKEN}` },
330
+ }),
288
331
  partnerId: process.env.PARTNER_ID,
289
332
  });
290
333
 
@@ -309,6 +352,10 @@ export { POST };
309
352
 
310
353
  ## Event Payload
311
354
 
355
+ The client sends a beacon to the activity endpoint. The server handler wraps it into a composite event and writes it to Kafka.
356
+
357
+ ### Client → Server (sendBeacon body)
358
+
312
359
  ```json
313
360
  {
314
361
  "name": "$view",
@@ -320,15 +367,64 @@ export { POST };
320
367
  "user_type": "standard",
321
368
  "distinct_id": "visitor-uuid",
322
369
  "origin": "yoursite.com",
323
- "source_location": "ProductsController#show"
370
+ "source_location": "ProductsController#show",
371
+ "context": {}
324
372
  },
325
373
  "visit": {
326
374
  "properties": {
327
375
  "landing_page": "https://yoursite.com/products/acme-crm",
328
376
  "referrer": "https://google.com/",
329
- "user_agent": "Mozilla/5.0",
330
- "utm_source": "newsletter"
377
+ "user_agent": "Mozilla/5.0 ...",
378
+ "utm_source": "newsletter",
379
+ "utm_medium": "email",
380
+ "utm_campaign": "spring-2026",
381
+ "utm_term": "crm+software",
382
+ "utm_content": "hero-cta"
331
383
  }
332
384
  }
333
385
  }
334
386
  ```
387
+
388
+ ### Server → Kafka (composite event)
389
+
390
+ The server enriches the client payload with server-side tokens, timestamps, and IP before writing to Kafka. This is the shape written to the `intent_events_{partnerId}` topic:
391
+
392
+ ```json
393
+ {
394
+ "event": {
395
+ "id": "e0c1f2a3-...",
396
+ "name": "$view",
397
+ "time": "2026-02-17T14:20:00.000Z",
398
+ "properties": {
399
+ "product_ids": [123],
400
+ "category_ids": [45],
401
+ "tag": "products.show",
402
+ "url": "https://yoursite.com/products/acme-crm",
403
+ "user_type": "standard",
404
+ "distinct_id": "visitor-uuid",
405
+ "origin": "yoursite.com",
406
+ "source_location": "ProductsController#show",
407
+ "context": {}
408
+ }
409
+ },
410
+ "visit": {
411
+ "visit_token": "a1b2c3d4-...",
412
+ "visitor_token": "f5e6d7c8-...",
413
+ "started_at": "2026-02-17T14:20:00.000Z",
414
+ "created_at": "2026-02-17T14:20:00.000Z",
415
+ "properties": {
416
+ "landing_page": "https://yoursite.com/products/acme-crm",
417
+ "referrer": "https://google.com/",
418
+ "user_agent": "Mozilla/5.0 ...",
419
+ "ip": "203.0.113.42",
420
+ "utm_source": "newsletter",
421
+ "utm_medium": "email",
422
+ "utm_campaign": "spring-2026",
423
+ "utm_term": "crm+software",
424
+ "utm_content": "hero-cta"
425
+ }
426
+ }
427
+ }
428
+ ```
429
+
430
+ The server sets `visit_token` and `visitor_token` as `httpOnly` cookies so repeat visits from the same browser share stable tokens.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@g2crowd/buyer-intent-provider-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Buyer intent tracking SDK with pageview defaults",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -26,17 +26,18 @@
26
26
  "url": "https://github.com/g2crowd/buyer-intent-provider-js"
27
27
  },
28
28
  "publishConfig": {
29
- "access": "public"
29
+ "access": "restricted",
30
+ "registry": "https://npm.pkg.github.com"
30
31
  },
31
32
  "peerDependencies": {
32
33
  "kafkajs": "^2.2.4",
33
34
  "react": ">=17"
34
35
  },
35
36
  "peerDependenciesMeta": {
36
- "react": {
37
+ "kafkajs": {
37
38
  "optional": true
38
39
  },
39
- "kafkajs": {
40
+ "react": {
40
41
  "optional": true
41
42
  }
42
43
  },
@@ -4,6 +4,7 @@ function buildViewAttrs({
4
4
  productId,
5
5
  productIds,
6
6
  categoryId,
7
+ categoryIds,
7
8
  tag,
8
9
  sourceLocation,
9
10
  context,
@@ -26,6 +27,7 @@ function buildViewAttrs({
26
27
  if (productId != null) attrs['product-id'] = String(productId);
27
28
  if (productIds) attrs['product-ids'] = JSON.stringify(productIds);
28
29
  if (categoryId != null) attrs['category-id'] = String(categoryId);
30
+ if (categoryIds) attrs['category-ids'] = JSON.stringify(categoryIds);
29
31
 
30
32
  return attrs;
31
33
  }
@@ -35,6 +37,7 @@ function buildClickAttrs({
35
37
  productId,
36
38
  productIds,
37
39
  categoryId,
40
+ categoryIds,
38
41
  sourceLocation,
39
42
  context,
40
43
  origin,
@@ -55,6 +58,7 @@ function buildClickAttrs({
55
58
  if (productId != null) attrs['product-id'] = String(productId);
56
59
  if (productIds) attrs['product-ids'] = JSON.stringify(productIds);
57
60
  if (categoryId != null) attrs['category-id'] = String(categoryId);
61
+ if (categoryIds) attrs['category-ids'] = JSON.stringify(categoryIds);
58
62
 
59
63
  return attrs;
60
64
  }
@@ -101,9 +101,9 @@ function parseIds(raw) {
101
101
  if (!raw) return [];
102
102
  try {
103
103
  const parsed = JSON.parse(raw);
104
- if (Array.isArray(parsed)) return parsed.map(Number);
104
+ if (Array.isArray(parsed)) return parsed;
105
105
  } catch (_e) {}
106
- return raw.split(',').map((s) => Number(s.trim())).filter((n) => !Number.isNaN(n));
106
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
107
107
  }
108
108
 
109
109
  export function readSessionConfig(element) {
@@ -139,7 +139,7 @@ export function collectSubjectIds(viewElement) {
139
139
  if (pids) {
140
140
  productIds.push(...parseIds(pids));
141
141
  } else if (pid) {
142
- productIds.push(Number(pid));
142
+ productIds.push(pid);
143
143
  }
144
144
 
145
145
  const cids = subject.getAttribute('category-ids');
@@ -147,7 +147,7 @@ export function collectSubjectIds(viewElement) {
147
147
  if (cids) {
148
148
  categoryIds.push(...parseIds(cids));
149
149
  } else if (cid) {
150
- categoryIds.push(Number(cid));
150
+ categoryIds.push(cid);
151
151
  }
152
152
  }
153
153
 
@@ -9,7 +9,7 @@ function parseIds(raw) {
9
9
  const parsed = JSON.parse(raw);
10
10
  if (Array.isArray(parsed)) return parsed;
11
11
  } catch (_e) {}
12
- return raw.split(',').map((s) => Number(s.trim()));
12
+ return raw.split(',').map((s) => s.trim()).filter(Boolean);
13
13
  }
14
14
 
15
15
  function readIntentData(element) {
@@ -21,11 +21,11 @@ function readIntentData(element) {
21
21
  if (productIds) {
22
22
  data.productIds = parseIds(productIds);
23
23
  } else if (productId) {
24
- data.productIds = [Number(productId)];
24
+ data.productIds = [productId];
25
25
  }
26
26
 
27
27
  if (categoryId) {
28
- data.categoryIds = [Number(categoryId)];
28
+ data.categoryIds = [categoryId];
29
29
  }
30
30
 
31
31
  const tag = element.getAttribute('tag');
@@ -5,4 +5,4 @@ export {
5
5
  BuyerIntentClickElement,
6
6
  } from './elements/index.js';
7
7
 
8
- export { createBuyerIntentSDK, buyerIntent } from './sdk.js';
8
+ export { createBuyerIntentSDK, ensureSDK, buyerIntent } from './sdk.js';
@@ -150,7 +150,7 @@ export function createBuyerIntentSDK() {
150
150
  function init(options = {}) {
151
151
  activityEndpoint = options.activityEndpoint || activityEndpoint;
152
152
  origin = options.origin || origin;
153
- viewName = options.viewName || options.pageviewName || viewName;
153
+ viewName = options.viewName || viewName;
154
154
  userType = options.userType || userType;
155
155
 
156
156
  if (options.distinctId) {
package/src/index.d.ts CHANGED
@@ -78,9 +78,10 @@ export type BuyerIntentInitOptions = {
78
78
  userType?: UserType;
79
79
  };
80
80
 
81
+ export type SubjectId = number | string;
81
82
  export type BuyerIntentTagOptions = {
82
- productIds?: number[];
83
- categoryIds?: number[];
83
+ productIds?: SubjectId[];
84
+ categoryIds?: SubjectId[];
84
85
  tag?: string;
85
86
  sourceLocation?: string;
86
87
  context?: Record<string, unknown>;
@@ -113,6 +114,7 @@ export type BuyerIntentSDK = {
113
114
  setBaseProperties: (options?: BuyerIntentBaseOptions) => void;
114
115
  setVisitProperties: (options?: BuyerIntentVisitProperties) => void;
115
116
  setActivityEndpoint: (endpoint: string) => void;
117
+ listenForElements: () => void;
116
118
  trackNextRouter: (nextRouter: {
117
119
  events?: {
118
120
  on: (event: string, handler: () => void) => void;
package/src/index.js CHANGED
@@ -1,6 +1,7 @@
1
1
  export {
2
2
  buyerIntent,
3
3
  createBuyerIntentSDK,
4
+ ensureSDK,
4
5
  BuyerIntentSessionElement,
5
6
  BuyerIntentSubjectElement,
6
7
  BuyerIntentViewElement,
@@ -1,5 +1,5 @@
1
1
  import type * as React from 'react';
2
- import type { UserType } from '../index';
2
+ import type { UserType, SubjectId } from '../index';
3
3
 
4
4
  type CommonProps = {
5
5
  sourceLocation?: string;
@@ -20,30 +20,32 @@ export type SessionProviderProps = {
20
20
  };
21
21
 
22
22
  export type SubjectTrackerProps = {
23
- productId?: number;
24
- categoryId?: number;
25
- productIds?: number[];
26
- categoryIds?: number[];
23
+ productId?: SubjectId;
24
+ categoryId?: SubjectId;
25
+ productIds?: SubjectId[];
26
+ categoryIds?: SubjectId[];
27
27
  };
28
28
 
29
29
  export type ViewTrackerProps = CommonProps & {
30
30
  tag?: string;
31
- productId?: number;
32
- productIds?: number[];
33
- categoryId?: number;
31
+ productId?: SubjectId;
32
+ productIds?: SubjectId[];
33
+ categoryId?: SubjectId;
34
+ categoryIds?: SubjectId[];
34
35
  };
35
36
 
36
37
  export type ClickTrackerProps = CommonProps & {
37
38
  eventName?: string;
38
- productId?: number;
39
- productIds?: number[];
40
- categoryId?: number;
39
+ productId?: SubjectId;
40
+ productIds?: SubjectId[];
41
+ categoryId?: SubjectId;
42
+ categoryIds?: SubjectId[];
41
43
  };
42
44
 
43
- type ProductIdViewProps = CommonProps & { productId: number };
44
- type CategoryViewProps = CommonProps & { categoryId: number };
45
- type ProductIdsViewProps = CommonProps & { productIds: number[] };
46
- type ProductIdClickProps = CommonProps & { productId: number };
45
+ type ProductIdViewProps = CommonProps & { productId: SubjectId };
46
+ type CategoryViewProps = CommonProps & { categoryId: SubjectId };
47
+ type ProductIdsViewProps = CommonProps & { productIds: SubjectId[] };
48
+ type ProductIdClickProps = CommonProps & { productId: SubjectId };
47
49
 
48
50
  export const SessionProvider: React.FC<SessionProviderProps>;
49
51
  export const SubjectTracker: React.FC<SubjectTrackerProps>;
@@ -71,38 +71,38 @@ function defaultGetIp({ headers, request }) {
71
71
  return undefined;
72
72
  }
73
73
 
74
- function ensureProducer({ producer, kafkaBrokers, kafkaClientId }) {
74
+ function resolveProducer({
75
+ producer,
76
+ kafkaBrokers,
77
+ kafkaClientId,
78
+ kafkaBridgeUrl,
79
+ kafkaBridgeUsername,
80
+ kafkaBridgePassword,
81
+ kafkaBridgeHeaders
82
+ }) {
75
83
  if (producer) {
76
84
  return producer;
77
85
  }
78
86
 
79
- if (!kafkaBrokers || !kafkaBrokers.length) {
80
- throw new Error('kafkaBrokers is required when no producer is provided');
87
+ if (kafkaBridgeUrl) {
88
+ return createHttpProducer({
89
+ bridgeUrl: kafkaBridgeUrl,
90
+ bridgeUsername: kafkaBridgeUsername,
91
+ bridgePassword: kafkaBridgePassword,
92
+ headers: kafkaBridgeHeaders
93
+ });
81
94
  }
82
95
 
83
- return {
84
- async connect() {
85
- if (this._producer) {
86
- return;
87
- }
88
- const module = await import('kafkajs');
89
- const kafka = new module.Kafka({
90
- clientId: kafkaClientId || 'buyer-intent-sdk',
91
- brokers: kafkaBrokers
92
- });
93
- this._producer = kafka.producer();
94
- await this._producer.connect();
95
- },
96
- async send(payload) {
97
- await this.connect();
98
- return this._producer.send(payload);
99
- },
100
- async disconnect() {
101
- if (this._producer) {
102
- await this._producer.disconnect();
103
- }
104
- }
105
- };
96
+ if (kafkaBrokers && kafkaBrokers.length) {
97
+ return createKafkaProducer({
98
+ brokers: kafkaBrokers,
99
+ clientId: kafkaClientId
100
+ });
101
+ }
102
+
103
+ throw new Error(
104
+ 'producer, kafkaBridgeUrl, or kafkaBrokers is required'
105
+ );
106
106
  }
107
107
 
108
108
  function buildCompositeEvent({
@@ -154,10 +154,14 @@ export function createActivityHandler(options = {}) {
154
154
  const nowFn = options.nowFn || (() => new Date().toISOString());
155
155
  const uuidFn = options.uuidFn || generateId;
156
156
  const getIp = options.getIp || defaultGetIp;
157
- const producer = ensureProducer({
157
+ const producer = resolveProducer({
158
158
  producer: options.producer,
159
159
  kafkaBrokers: options.kafkaBrokers,
160
- kafkaClientId: options.kafkaClientId
160
+ kafkaClientId: options.kafkaClientId,
161
+ kafkaBridgeUrl: options.kafkaBridgeUrl,
162
+ kafkaBridgeUsername: options.kafkaBridgeUsername,
163
+ kafkaBridgePassword: options.kafkaBridgePassword,
164
+ kafkaBridgeHeaders: options.kafkaBridgeHeaders
161
165
  });
162
166
 
163
167
  return async function handle({ body, headers, cookies, request }) {
@@ -257,6 +261,76 @@ export function createNextRouteHandler(options = {}) {
257
261
  };
258
262
  }
259
263
 
264
+ export function createKafkaProducer(options = {}) {
265
+ const brokers = options.brokers;
266
+ if (!brokers || !brokers.length) {
267
+ throw new Error('brokers is required for createKafkaProducer');
268
+ }
269
+
270
+ const clientId = options.clientId || 'buyer-intent-sdk';
271
+ let _producer;
272
+
273
+ return {
274
+ async connect() {
275
+ if (_producer) {
276
+ return;
277
+ }
278
+ const module = await import('kafkajs');
279
+ const kafka = new module.Kafka({ clientId, brokers });
280
+ _producer = kafka.producer();
281
+ await _producer.connect();
282
+ },
283
+ async send(payload) {
284
+ await this.connect();
285
+ return _producer.send(payload);
286
+ },
287
+ async disconnect() {
288
+ if (_producer) {
289
+ await _producer.disconnect();
290
+ }
291
+ }
292
+ };
293
+ }
294
+
295
+ export function createHttpProducer(options = {}) {
296
+ const bridgeUrl = options.bridgeUrl;
297
+ if (!bridgeUrl) {
298
+ throw new Error('bridgeUrl is required for createHttpProducer');
299
+ }
300
+
301
+ const headers = {
302
+ 'Content-Type': 'application/vnd.kafka.json.v2+json',
303
+ ...(options.bridgeUsername && {
304
+ 'Authorization': `Basic ${Buffer.from(`${options.bridgeUsername}:${options.bridgePassword || ''}`).toString('base64')}`
305
+ }),
306
+ ...options.headers
307
+ };
308
+
309
+ return {
310
+ async send(payload) {
311
+ const url = `${bridgeUrl.replace(/\/+$/, '')}/topics/${encodeURIComponent(payload.topic)}`;
312
+ const records = payload.messages.map((msg) => ({
313
+ value: typeof msg.value === 'string' ? JSON.parse(msg.value) : msg.value
314
+ }));
315
+
316
+ const response = await fetch(url, {
317
+ method: 'POST',
318
+ headers,
319
+ body: JSON.stringify({ records })
320
+ });
321
+
322
+ if (!response.ok) {
323
+ const body = await response.text();
324
+ throw new Error(
325
+ `Kafka bridge error ${response.status}: ${body}`
326
+ );
327
+ }
328
+
329
+ return response.json();
330
+ }
331
+ };
332
+ }
333
+
260
334
  export function createDevLoggerProducer(options = {}) {
261
335
  const log = options.log || console.log;
262
336
 
@@ -1,6 +1,8 @@
1
1
  export {
2
2
  createActivityHandler,
3
3
  createNextRouteHandler,
4
+ createKafkaProducer,
5
+ createHttpProducer,
4
6
  createDevLoggerProducer,
5
7
  topicName
6
8
  } from './handlers.js';
package/src/server.js CHANGED
@@ -42,6 +42,9 @@ function buildHandlerOptions() {
42
42
  return {
43
43
  kafkaBrokers: brokers,
44
44
  kafkaClientId: process.env.KAFKA_CLIENT_ID,
45
+ kafkaBridgeUrl: process.env.KAFKA_BRIDGE_URL,
46
+ kafkaBridgeUsername: process.env.KAFKA_BRIDGE_USERNAME,
47
+ kafkaBridgePassword: process.env.KAFKA_BRIDGE_PASSWORD,
45
48
  partnerId: process.env.PARTNER_ID,
46
49
  topicPrefix: process.env.TOPIC_PREFIX,
47
50
  topic: process.env.KAFKA_TOPIC,