@pocketping/sdk-node 0.1.0 → 0.2.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/dist/index.js CHANGED
@@ -26,6 +26,7 @@ __export(index_exports, {
26
26
  module.exports = __toCommonJS(index_exports);
27
27
 
28
28
  // src/pocketping.ts
29
+ var import_crypto = require("crypto");
29
30
  var import_ws = require("ws");
30
31
 
31
32
  // src/storage/memory.ts
@@ -131,11 +132,27 @@ function parseUserAgent(userAgent) {
131
132
  else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS";
132
133
  return { deviceType, browser, os };
133
134
  }
135
+ function parseVersion(version) {
136
+ return version.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
137
+ }
138
+ function compareVersions(a, b) {
139
+ const vA = parseVersion(a);
140
+ const vB = parseVersion(b);
141
+ const len = Math.max(vA.length, vB.length);
142
+ for (let i = 0; i < len; i++) {
143
+ const numA = vA[i] ?? 0;
144
+ const numB = vB[i] ?? 0;
145
+ if (numA < numB) return -1;
146
+ if (numA > numB) return 1;
147
+ }
148
+ return 0;
149
+ }
134
150
  var PocketPing = class {
135
151
  constructor(config = {}) {
136
152
  this.wss = null;
137
153
  this.sessionSockets = /* @__PURE__ */ new Map();
138
154
  this.operatorOnline = false;
155
+ this.eventHandlers = /* @__PURE__ */ new Map();
139
156
  this.config = config;
140
157
  this.storage = this.initStorage(config.storage);
141
158
  this.bridges = config.bridges ?? [];
@@ -155,16 +172,32 @@ var PocketPing = class {
155
172
  const path = url.pathname.replace(/^\/+/, "").replace(/\/+$/, "");
156
173
  res.setHeader("Access-Control-Allow-Origin", "*");
157
174
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
158
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
175
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-PocketPing-Version");
176
+ res.setHeader("Access-Control-Expose-Headers", "X-PocketPing-Version-Status, X-PocketPing-Min-Version, X-PocketPing-Latest-Version, X-PocketPing-Version-Message");
159
177
  if (req.method === "OPTIONS") {
160
178
  res.statusCode = 204;
161
179
  res.end();
162
180
  return;
163
181
  }
182
+ const widgetVersion = req.headers["x-pocketping-version"];
183
+ const versionCheck = this.checkWidgetVersion(widgetVersion);
184
+ this.setVersionHeaders(res, versionCheck);
185
+ if (!versionCheck.canContinue) {
186
+ res.statusCode = 426;
187
+ res.setHeader("Content-Type", "application/json");
188
+ res.end(JSON.stringify({
189
+ error: "Widget version unsupported",
190
+ message: versionCheck.message,
191
+ minVersion: versionCheck.minVersion,
192
+ upgradeUrl: this.config.versionUpgradeUrl || "https://docs.pocketping.io/widget/installation"
193
+ }));
194
+ return;
195
+ }
164
196
  try {
165
197
  const body = await this.parseBody(req);
166
198
  const query = Object.fromEntries(url.searchParams);
167
199
  let result;
200
+ let sessionId;
168
201
  switch (path) {
169
202
  case "connect": {
170
203
  const connectReq = body;
@@ -183,7 +216,9 @@ var PocketPing = class {
183
216
  ...uaInfo
184
217
  };
185
218
  }
186
- result = await this.handleConnect(connectReq);
219
+ const connectResult = await this.handleConnect(connectReq);
220
+ sessionId = connectResult.sessionId;
221
+ result = connectResult;
187
222
  break;
188
223
  }
189
224
  case "message":
@@ -201,6 +236,9 @@ var PocketPing = class {
201
236
  case "read":
202
237
  result = await this.handleRead(body);
203
238
  break;
239
+ case "identify":
240
+ result = await this.handleIdentify(body);
241
+ break;
204
242
  default:
205
243
  if (next) {
206
244
  next();
@@ -213,6 +251,11 @@ var PocketPing = class {
213
251
  res.setHeader("Content-Type", "application/json");
214
252
  res.statusCode = 200;
215
253
  res.end(JSON.stringify(result));
254
+ if (sessionId && versionCheck.status !== "ok") {
255
+ setTimeout(() => {
256
+ this.sendVersionWarning(sessionId, versionCheck);
257
+ }, 500);
258
+ }
216
259
  } catch (error) {
217
260
  console.error("[PocketPing] Error:", error);
218
261
  res.statusCode = 500;
@@ -276,7 +319,42 @@ var PocketPing = class {
276
319
  data: event.data
277
320
  });
278
321
  break;
322
+ case "event":
323
+ const customEvent = event.data;
324
+ customEvent.sessionId = sessionId;
325
+ await this.handleCustomEvent(sessionId, customEvent);
326
+ break;
327
+ }
328
+ }
329
+ async handleCustomEvent(sessionId, event) {
330
+ const session = await this.storage.getSession(sessionId);
331
+ if (!session) {
332
+ console.warn(`[PocketPing] Event received for unknown session: ${sessionId}`);
333
+ return;
334
+ }
335
+ const handlers = this.eventHandlers.get(event.name);
336
+ if (handlers) {
337
+ for (const handler of handlers) {
338
+ try {
339
+ await handler(event, session);
340
+ } catch (err) {
341
+ console.error(`[PocketPing] Event handler error for '${event.name}':`, err);
342
+ }
343
+ }
344
+ }
345
+ const wildcardHandlers = this.eventHandlers.get("*");
346
+ if (wildcardHandlers) {
347
+ for (const handler of wildcardHandlers) {
348
+ try {
349
+ await handler(event, session);
350
+ } catch (err) {
351
+ console.error(`[PocketPing] Wildcard event handler error:`, err);
352
+ }
353
+ }
279
354
  }
355
+ await this.config.onEvent?.(event, session);
356
+ await this.notifyBridgesEvent(event, session);
357
+ this.forwardToWebhook(event, session);
280
358
  }
281
359
  broadcastToSession(sessionId, event) {
282
360
  const sockets = this.sessionSockets.get(sessionId);
@@ -307,20 +385,31 @@ var PocketPing = class {
307
385
  lastActivity: /* @__PURE__ */ new Date(),
308
386
  operatorOnline: this.operatorOnline,
309
387
  aiActive: false,
310
- metadata: request.metadata
388
+ metadata: request.metadata,
389
+ identity: request.identity
311
390
  };
312
391
  await this.storage.createSession(session);
313
392
  await this.notifyBridges("new_session", session);
314
393
  await this.config.onNewSession?.(session);
315
- } else if (request.metadata) {
316
- if (session.metadata) {
317
- request.metadata.ip = session.metadata.ip ?? request.metadata.ip;
318
- request.metadata.country = session.metadata.country ?? request.metadata.country;
319
- request.metadata.city = session.metadata.city ?? request.metadata.city;
394
+ } else {
395
+ let needsUpdate = false;
396
+ if (request.metadata) {
397
+ if (session.metadata) {
398
+ request.metadata.ip = session.metadata.ip ?? request.metadata.ip;
399
+ request.metadata.country = session.metadata.country ?? request.metadata.country;
400
+ request.metadata.city = session.metadata.city ?? request.metadata.city;
401
+ }
402
+ session.metadata = request.metadata;
403
+ needsUpdate = true;
404
+ }
405
+ if (request.identity) {
406
+ session.identity = request.identity;
407
+ needsUpdate = true;
408
+ }
409
+ if (needsUpdate) {
410
+ session.lastActivity = /* @__PURE__ */ new Date();
411
+ await this.storage.updateSession(session);
320
412
  }
321
- session.metadata = request.metadata;
322
- session.lastActivity = /* @__PURE__ */ new Date();
323
- await this.storage.updateSession(session);
324
413
  }
325
414
  const messages = await this.storage.getMessages(session.id);
326
415
  return {
@@ -421,6 +510,35 @@ var PocketPing = class {
421
510
  return { updated };
422
511
  }
423
512
  // ─────────────────────────────────────────────────────────────────
513
+ // User Identity
514
+ // ─────────────────────────────────────────────────────────────────
515
+ /**
516
+ * Handle user identification from widget
517
+ * Called when visitor calls PocketPing.identify()
518
+ */
519
+ async handleIdentify(request) {
520
+ if (!request.identity?.id) {
521
+ throw new Error("identity.id is required");
522
+ }
523
+ const session = await this.storage.getSession(request.sessionId);
524
+ if (!session) {
525
+ throw new Error("Session not found");
526
+ }
527
+ session.identity = request.identity;
528
+ session.lastActivity = /* @__PURE__ */ new Date();
529
+ await this.storage.updateSession(session);
530
+ await this.notifyBridgesIdentity(session);
531
+ await this.config.onIdentify?.(session);
532
+ this.forwardIdentityToWebhook(session);
533
+ return { ok: true };
534
+ }
535
+ /**
536
+ * Get a session by ID
537
+ */
538
+ async getSession(sessionId) {
539
+ return this.storage.getSession(sessionId);
540
+ }
541
+ // ─────────────────────────────────────────────────────────────────
424
542
  // Operator Actions (for bridges)
425
543
  // ─────────────────────────────────────────────────────────────────
426
544
  async sendOperatorMessage(sessionId, content) {
@@ -448,6 +566,113 @@ var PocketPing = class {
448
566
  }
449
567
  }
450
568
  // ─────────────────────────────────────────────────────────────────
569
+ // Custom Events (bidirectional)
570
+ // ─────────────────────────────────────────────────────────────────
571
+ /**
572
+ * Subscribe to custom events from widgets
573
+ * @param eventName - The name of the event to listen for, or '*' for all events
574
+ * @param handler - Callback function when event is received
575
+ * @returns Unsubscribe function
576
+ * @example
577
+ * // Listen for specific event
578
+ * pp.onEvent('clicked_pricing', async (event, session) => {
579
+ * console.log(`User ${session.visitorId} clicked pricing: ${event.data?.plan}`)
580
+ * })
581
+ *
582
+ * // Listen for all events
583
+ * pp.onEvent('*', async (event, session) => {
584
+ * console.log(`Event: ${event.name}`, event.data)
585
+ * })
586
+ */
587
+ onEvent(eventName, handler) {
588
+ if (!this.eventHandlers.has(eventName)) {
589
+ this.eventHandlers.set(eventName, /* @__PURE__ */ new Set());
590
+ }
591
+ this.eventHandlers.get(eventName).add(handler);
592
+ return () => {
593
+ this.eventHandlers.get(eventName)?.delete(handler);
594
+ };
595
+ }
596
+ /**
597
+ * Unsubscribe from a custom event
598
+ * @param eventName - The name of the event
599
+ * @param handler - The handler to remove
600
+ */
601
+ offEvent(eventName, handler) {
602
+ this.eventHandlers.get(eventName)?.delete(handler);
603
+ }
604
+ /**
605
+ * Send a custom event to a specific widget/session
606
+ * @param sessionId - The session ID to send the event to
607
+ * @param eventName - The name of the event
608
+ * @param data - Optional payload to send with the event
609
+ * @example
610
+ * // Send a promotion offer to a specific user
611
+ * pp.emitEvent('session-123', 'show_offer', {
612
+ * discount: 20,
613
+ * code: 'SAVE20',
614
+ * message: 'Special offer just for you!'
615
+ * })
616
+ */
617
+ emitEvent(sessionId, eventName, data) {
618
+ const event = {
619
+ name: eventName,
620
+ data,
621
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
622
+ sessionId
623
+ };
624
+ this.broadcastToSession(sessionId, {
625
+ type: "event",
626
+ data: event
627
+ });
628
+ }
629
+ /**
630
+ * Broadcast a custom event to all connected widgets
631
+ * @param eventName - The name of the event
632
+ * @param data - Optional payload to send with the event
633
+ * @example
634
+ * // Notify all users about maintenance
635
+ * pp.broadcastEvent('maintenance_warning', {
636
+ * message: 'Site will be down for maintenance in 5 minutes'
637
+ * })
638
+ */
639
+ broadcastEvent(eventName, data) {
640
+ const event = {
641
+ name: eventName,
642
+ data,
643
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
644
+ };
645
+ for (const sessionId of this.sessionSockets.keys()) {
646
+ event.sessionId = sessionId;
647
+ this.broadcastToSession(sessionId, {
648
+ type: "event",
649
+ data: event
650
+ });
651
+ }
652
+ }
653
+ /**
654
+ * Process a custom event server-side (runs handlers, bridges, webhooks)
655
+ * Useful for server-side automation or triggering events programmatically
656
+ * @param sessionId - The session ID to associate with the event
657
+ * @param eventName - The name of the event
658
+ * @param data - Optional payload for the event
659
+ * @example
660
+ * // Trigger event from backend logic (e.g., after purchase)
661
+ * await pp.triggerEvent('session-123', 'purchase_completed', {
662
+ * orderId: 'order-456',
663
+ * amount: 99.99
664
+ * })
665
+ */
666
+ async triggerEvent(sessionId, eventName, data) {
667
+ const event = {
668
+ name: eventName,
669
+ data,
670
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
671
+ sessionId
672
+ };
673
+ await this.handleCustomEvent(sessionId, event);
674
+ }
675
+ // ─────────────────────────────────────────────────────────────────
451
676
  // Bridges
452
677
  // ─────────────────────────────────────────────────────────────────
453
678
  async notifyBridges(event, ...args) {
@@ -475,6 +700,86 @@ var PocketPing = class {
475
700
  }
476
701
  }
477
702
  }
703
+ async notifyBridgesEvent(event, session) {
704
+ for (const bridge of this.bridges) {
705
+ try {
706
+ await bridge.onEvent?.(event, session);
707
+ } catch (err) {
708
+ console.error(`[PocketPing] Bridge ${bridge.name} event notification error:`, err);
709
+ }
710
+ }
711
+ }
712
+ async notifyBridgesIdentity(session) {
713
+ for (const bridge of this.bridges) {
714
+ try {
715
+ await bridge.onIdentityUpdate?.(session);
716
+ } catch (err) {
717
+ console.error(`[PocketPing] Bridge ${bridge.name} identity notification error:`, err);
718
+ }
719
+ }
720
+ }
721
+ // ─────────────────────────────────────────────────────────────────
722
+ // Webhook Forwarding
723
+ // ─────────────────────────────────────────────────────────────────
724
+ /**
725
+ * Forward custom event to configured webhook URL (non-blocking)
726
+ * Used for integrations with Zapier, Make, n8n, or custom backends
727
+ */
728
+ forwardToWebhook(event, session) {
729
+ if (!this.config.webhookUrl) return;
730
+ const payload = {
731
+ event,
732
+ session: {
733
+ id: session.id,
734
+ visitorId: session.visitorId,
735
+ metadata: session.metadata,
736
+ identity: session.identity
737
+ },
738
+ sentAt: (/* @__PURE__ */ new Date()).toISOString()
739
+ };
740
+ const body = JSON.stringify(payload);
741
+ const headers = {
742
+ "Content-Type": "application/json"
743
+ };
744
+ if (this.config.webhookSecret) {
745
+ const signature = (0, import_crypto.createHmac)("sha256", this.config.webhookSecret).update(body).digest("hex");
746
+ headers["X-PocketPing-Signature"] = `sha256=${signature}`;
747
+ }
748
+ const timeout = this.config.webhookTimeout ?? 5e3;
749
+ const controller = new AbortController();
750
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
751
+ fetch(this.config.webhookUrl, {
752
+ method: "POST",
753
+ headers,
754
+ body,
755
+ signal: controller.signal
756
+ }).then((response) => {
757
+ clearTimeout(timeoutId);
758
+ if (!response.ok) {
759
+ console.error(`[PocketPing] Webhook returned ${response.status}: ${response.statusText}`);
760
+ }
761
+ }).catch((err) => {
762
+ clearTimeout(timeoutId);
763
+ if (err.name === "AbortError") {
764
+ console.error(`[PocketPing] Webhook timed out after ${timeout}ms`);
765
+ } else {
766
+ console.error(`[PocketPing] Webhook error:`, err.message);
767
+ }
768
+ });
769
+ }
770
+ /**
771
+ * Forward identity update to webhook as a special event
772
+ */
773
+ forwardIdentityToWebhook(session) {
774
+ if (!this.config.webhookUrl || !session.identity) return;
775
+ const event = {
776
+ name: "identify",
777
+ data: session.identity,
778
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
779
+ sessionId: session.id
780
+ };
781
+ this.forwardToWebhook(event, session);
782
+ }
478
783
  addBridge(bridge) {
479
784
  this.bridges.push(bridge);
480
785
  bridge.init?.(this);
@@ -488,6 +793,89 @@ var PocketPing = class {
488
793
  getStorage() {
489
794
  return this.storage;
490
795
  }
796
+ // ─────────────────────────────────────────────────────────────────
797
+ // Version Management
798
+ // ─────────────────────────────────────────────────────────────────
799
+ /**
800
+ * Check widget version against configured min/latest versions
801
+ * @param widgetVersion - Version from X-PocketPing-Version header
802
+ * @returns Version check result with status and headers to set
803
+ */
804
+ checkWidgetVersion(widgetVersion) {
805
+ if (!widgetVersion) {
806
+ return {
807
+ status: "ok",
808
+ canContinue: true
809
+ };
810
+ }
811
+ const { minWidgetVersion, latestWidgetVersion } = this.config;
812
+ if (!minWidgetVersion && !latestWidgetVersion) {
813
+ return {
814
+ status: "ok",
815
+ canContinue: true
816
+ };
817
+ }
818
+ let status = "ok";
819
+ let message;
820
+ let canContinue = true;
821
+ if (minWidgetVersion && compareVersions(widgetVersion, minWidgetVersion) < 0) {
822
+ status = "unsupported";
823
+ message = this.config.versionWarningMessage || `Widget version ${widgetVersion} is no longer supported. Minimum version: ${minWidgetVersion}`;
824
+ canContinue = false;
825
+ } else if (latestWidgetVersion && compareVersions(widgetVersion, latestWidgetVersion) < 0) {
826
+ const majorDiff = parseVersion(latestWidgetVersion)[0] - parseVersion(widgetVersion)[0];
827
+ if (majorDiff >= 1) {
828
+ status = "deprecated";
829
+ message = this.config.versionWarningMessage || `Widget version ${widgetVersion} is deprecated. Please update to ${latestWidgetVersion}`;
830
+ } else {
831
+ status = "outdated";
832
+ message = `A newer widget version ${latestWidgetVersion} is available`;
833
+ }
834
+ }
835
+ return {
836
+ status,
837
+ message,
838
+ minVersion: minWidgetVersion,
839
+ latestVersion: latestWidgetVersion,
840
+ canContinue
841
+ };
842
+ }
843
+ /**
844
+ * Set version warning headers on HTTP response
845
+ */
846
+ setVersionHeaders(res, versionCheck) {
847
+ if (versionCheck.status !== "ok") {
848
+ res.setHeader("X-PocketPing-Version-Status", versionCheck.status);
849
+ if (versionCheck.minVersion) {
850
+ res.setHeader("X-PocketPing-Min-Version", versionCheck.minVersion);
851
+ }
852
+ if (versionCheck.latestVersion) {
853
+ res.setHeader("X-PocketPing-Latest-Version", versionCheck.latestVersion);
854
+ }
855
+ if (versionCheck.message) {
856
+ res.setHeader("X-PocketPing-Version-Message", versionCheck.message);
857
+ }
858
+ }
859
+ }
860
+ /**
861
+ * Send version warning via WebSocket to a session
862
+ */
863
+ sendVersionWarning(sessionId, versionCheck) {
864
+ if (versionCheck.status === "ok") return;
865
+ this.broadcastToSession(sessionId, {
866
+ type: "version_warning",
867
+ data: {
868
+ severity: versionCheck.status === "unsupported" ? "error" : versionCheck.status === "deprecated" ? "warning" : "info",
869
+ message: versionCheck.message,
870
+ currentVersion: "unknown",
871
+ // Will be filled by widget
872
+ minVersion: versionCheck.minVersion,
873
+ latestVersion: versionCheck.latestVersion,
874
+ canContinue: versionCheck.canContinue,
875
+ upgradeUrl: this.config.versionUpgradeUrl || "https://docs.pocketping.io/widget/installation"
876
+ }
877
+ });
878
+ }
491
879
  };
492
880
  // Annotate the CommonJS export names for ESM import in node:
493
881
  0 && (module.exports = {