@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.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  // src/pocketping.ts
2
+ import { createHmac } from "crypto";
2
3
  import { WebSocketServer, WebSocket } from "ws";
3
4
 
4
5
  // src/storage/memory.ts
@@ -104,11 +105,27 @@ function parseUserAgent(userAgent) {
104
105
  else if (ua.includes("iphone") || ua.includes("ipad")) os = "iOS";
105
106
  return { deviceType, browser, os };
106
107
  }
108
+ function parseVersion(version) {
109
+ return version.replace(/^v/, "").split(".").map((n) => parseInt(n, 10) || 0);
110
+ }
111
+ function compareVersions(a, b) {
112
+ const vA = parseVersion(a);
113
+ const vB = parseVersion(b);
114
+ const len = Math.max(vA.length, vB.length);
115
+ for (let i = 0; i < len; i++) {
116
+ const numA = vA[i] ?? 0;
117
+ const numB = vB[i] ?? 0;
118
+ if (numA < numB) return -1;
119
+ if (numA > numB) return 1;
120
+ }
121
+ return 0;
122
+ }
107
123
  var PocketPing = class {
108
124
  constructor(config = {}) {
109
125
  this.wss = null;
110
126
  this.sessionSockets = /* @__PURE__ */ new Map();
111
127
  this.operatorOnline = false;
128
+ this.eventHandlers = /* @__PURE__ */ new Map();
112
129
  this.config = config;
113
130
  this.storage = this.initStorage(config.storage);
114
131
  this.bridges = config.bridges ?? [];
@@ -128,16 +145,32 @@ var PocketPing = class {
128
145
  const path = url.pathname.replace(/^\/+/, "").replace(/\/+$/, "");
129
146
  res.setHeader("Access-Control-Allow-Origin", "*");
130
147
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
131
- res.setHeader("Access-Control-Allow-Headers", "Content-Type");
148
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type, X-PocketPing-Version");
149
+ res.setHeader("Access-Control-Expose-Headers", "X-PocketPing-Version-Status, X-PocketPing-Min-Version, X-PocketPing-Latest-Version, X-PocketPing-Version-Message");
132
150
  if (req.method === "OPTIONS") {
133
151
  res.statusCode = 204;
134
152
  res.end();
135
153
  return;
136
154
  }
155
+ const widgetVersion = req.headers["x-pocketping-version"];
156
+ const versionCheck = this.checkWidgetVersion(widgetVersion);
157
+ this.setVersionHeaders(res, versionCheck);
158
+ if (!versionCheck.canContinue) {
159
+ res.statusCode = 426;
160
+ res.setHeader("Content-Type", "application/json");
161
+ res.end(JSON.stringify({
162
+ error: "Widget version unsupported",
163
+ message: versionCheck.message,
164
+ minVersion: versionCheck.minVersion,
165
+ upgradeUrl: this.config.versionUpgradeUrl || "https://docs.pocketping.io/widget/installation"
166
+ }));
167
+ return;
168
+ }
137
169
  try {
138
170
  const body = await this.parseBody(req);
139
171
  const query = Object.fromEntries(url.searchParams);
140
172
  let result;
173
+ let sessionId;
141
174
  switch (path) {
142
175
  case "connect": {
143
176
  const connectReq = body;
@@ -156,7 +189,9 @@ var PocketPing = class {
156
189
  ...uaInfo
157
190
  };
158
191
  }
159
- result = await this.handleConnect(connectReq);
192
+ const connectResult = await this.handleConnect(connectReq);
193
+ sessionId = connectResult.sessionId;
194
+ result = connectResult;
160
195
  break;
161
196
  }
162
197
  case "message":
@@ -174,6 +209,9 @@ var PocketPing = class {
174
209
  case "read":
175
210
  result = await this.handleRead(body);
176
211
  break;
212
+ case "identify":
213
+ result = await this.handleIdentify(body);
214
+ break;
177
215
  default:
178
216
  if (next) {
179
217
  next();
@@ -186,6 +224,11 @@ var PocketPing = class {
186
224
  res.setHeader("Content-Type", "application/json");
187
225
  res.statusCode = 200;
188
226
  res.end(JSON.stringify(result));
227
+ if (sessionId && versionCheck.status !== "ok") {
228
+ setTimeout(() => {
229
+ this.sendVersionWarning(sessionId, versionCheck);
230
+ }, 500);
231
+ }
189
232
  } catch (error) {
190
233
  console.error("[PocketPing] Error:", error);
191
234
  res.statusCode = 500;
@@ -249,7 +292,42 @@ var PocketPing = class {
249
292
  data: event.data
250
293
  });
251
294
  break;
295
+ case "event":
296
+ const customEvent = event.data;
297
+ customEvent.sessionId = sessionId;
298
+ await this.handleCustomEvent(sessionId, customEvent);
299
+ break;
300
+ }
301
+ }
302
+ async handleCustomEvent(sessionId, event) {
303
+ const session = await this.storage.getSession(sessionId);
304
+ if (!session) {
305
+ console.warn(`[PocketPing] Event received for unknown session: ${sessionId}`);
306
+ return;
307
+ }
308
+ const handlers = this.eventHandlers.get(event.name);
309
+ if (handlers) {
310
+ for (const handler of handlers) {
311
+ try {
312
+ await handler(event, session);
313
+ } catch (err) {
314
+ console.error(`[PocketPing] Event handler error for '${event.name}':`, err);
315
+ }
316
+ }
317
+ }
318
+ const wildcardHandlers = this.eventHandlers.get("*");
319
+ if (wildcardHandlers) {
320
+ for (const handler of wildcardHandlers) {
321
+ try {
322
+ await handler(event, session);
323
+ } catch (err) {
324
+ console.error(`[PocketPing] Wildcard event handler error:`, err);
325
+ }
326
+ }
252
327
  }
328
+ await this.config.onEvent?.(event, session);
329
+ await this.notifyBridgesEvent(event, session);
330
+ this.forwardToWebhook(event, session);
253
331
  }
254
332
  broadcastToSession(sessionId, event) {
255
333
  const sockets = this.sessionSockets.get(sessionId);
@@ -280,20 +358,31 @@ var PocketPing = class {
280
358
  lastActivity: /* @__PURE__ */ new Date(),
281
359
  operatorOnline: this.operatorOnline,
282
360
  aiActive: false,
283
- metadata: request.metadata
361
+ metadata: request.metadata,
362
+ identity: request.identity
284
363
  };
285
364
  await this.storage.createSession(session);
286
365
  await this.notifyBridges("new_session", session);
287
366
  await this.config.onNewSession?.(session);
288
- } else if (request.metadata) {
289
- if (session.metadata) {
290
- request.metadata.ip = session.metadata.ip ?? request.metadata.ip;
291
- request.metadata.country = session.metadata.country ?? request.metadata.country;
292
- request.metadata.city = session.metadata.city ?? request.metadata.city;
367
+ } else {
368
+ let needsUpdate = false;
369
+ if (request.metadata) {
370
+ if (session.metadata) {
371
+ request.metadata.ip = session.metadata.ip ?? request.metadata.ip;
372
+ request.metadata.country = session.metadata.country ?? request.metadata.country;
373
+ request.metadata.city = session.metadata.city ?? request.metadata.city;
374
+ }
375
+ session.metadata = request.metadata;
376
+ needsUpdate = true;
377
+ }
378
+ if (request.identity) {
379
+ session.identity = request.identity;
380
+ needsUpdate = true;
381
+ }
382
+ if (needsUpdate) {
383
+ session.lastActivity = /* @__PURE__ */ new Date();
384
+ await this.storage.updateSession(session);
293
385
  }
294
- session.metadata = request.metadata;
295
- session.lastActivity = /* @__PURE__ */ new Date();
296
- await this.storage.updateSession(session);
297
386
  }
298
387
  const messages = await this.storage.getMessages(session.id);
299
388
  return {
@@ -394,6 +483,35 @@ var PocketPing = class {
394
483
  return { updated };
395
484
  }
396
485
  // ─────────────────────────────────────────────────────────────────
486
+ // User Identity
487
+ // ─────────────────────────────────────────────────────────────────
488
+ /**
489
+ * Handle user identification from widget
490
+ * Called when visitor calls PocketPing.identify()
491
+ */
492
+ async handleIdentify(request) {
493
+ if (!request.identity?.id) {
494
+ throw new Error("identity.id is required");
495
+ }
496
+ const session = await this.storage.getSession(request.sessionId);
497
+ if (!session) {
498
+ throw new Error("Session not found");
499
+ }
500
+ session.identity = request.identity;
501
+ session.lastActivity = /* @__PURE__ */ new Date();
502
+ await this.storage.updateSession(session);
503
+ await this.notifyBridgesIdentity(session);
504
+ await this.config.onIdentify?.(session);
505
+ this.forwardIdentityToWebhook(session);
506
+ return { ok: true };
507
+ }
508
+ /**
509
+ * Get a session by ID
510
+ */
511
+ async getSession(sessionId) {
512
+ return this.storage.getSession(sessionId);
513
+ }
514
+ // ─────────────────────────────────────────────────────────────────
397
515
  // Operator Actions (for bridges)
398
516
  // ─────────────────────────────────────────────────────────────────
399
517
  async sendOperatorMessage(sessionId, content) {
@@ -421,6 +539,113 @@ var PocketPing = class {
421
539
  }
422
540
  }
423
541
  // ─────────────────────────────────────────────────────────────────
542
+ // Custom Events (bidirectional)
543
+ // ─────────────────────────────────────────────────────────────────
544
+ /**
545
+ * Subscribe to custom events from widgets
546
+ * @param eventName - The name of the event to listen for, or '*' for all events
547
+ * @param handler - Callback function when event is received
548
+ * @returns Unsubscribe function
549
+ * @example
550
+ * // Listen for specific event
551
+ * pp.onEvent('clicked_pricing', async (event, session) => {
552
+ * console.log(`User ${session.visitorId} clicked pricing: ${event.data?.plan}`)
553
+ * })
554
+ *
555
+ * // Listen for all events
556
+ * pp.onEvent('*', async (event, session) => {
557
+ * console.log(`Event: ${event.name}`, event.data)
558
+ * })
559
+ */
560
+ onEvent(eventName, handler) {
561
+ if (!this.eventHandlers.has(eventName)) {
562
+ this.eventHandlers.set(eventName, /* @__PURE__ */ new Set());
563
+ }
564
+ this.eventHandlers.get(eventName).add(handler);
565
+ return () => {
566
+ this.eventHandlers.get(eventName)?.delete(handler);
567
+ };
568
+ }
569
+ /**
570
+ * Unsubscribe from a custom event
571
+ * @param eventName - The name of the event
572
+ * @param handler - The handler to remove
573
+ */
574
+ offEvent(eventName, handler) {
575
+ this.eventHandlers.get(eventName)?.delete(handler);
576
+ }
577
+ /**
578
+ * Send a custom event to a specific widget/session
579
+ * @param sessionId - The session ID to send the event to
580
+ * @param eventName - The name of the event
581
+ * @param data - Optional payload to send with the event
582
+ * @example
583
+ * // Send a promotion offer to a specific user
584
+ * pp.emitEvent('session-123', 'show_offer', {
585
+ * discount: 20,
586
+ * code: 'SAVE20',
587
+ * message: 'Special offer just for you!'
588
+ * })
589
+ */
590
+ emitEvent(sessionId, eventName, data) {
591
+ const event = {
592
+ name: eventName,
593
+ data,
594
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
595
+ sessionId
596
+ };
597
+ this.broadcastToSession(sessionId, {
598
+ type: "event",
599
+ data: event
600
+ });
601
+ }
602
+ /**
603
+ * Broadcast a custom event to all connected widgets
604
+ * @param eventName - The name of the event
605
+ * @param data - Optional payload to send with the event
606
+ * @example
607
+ * // Notify all users about maintenance
608
+ * pp.broadcastEvent('maintenance_warning', {
609
+ * message: 'Site will be down for maintenance in 5 minutes'
610
+ * })
611
+ */
612
+ broadcastEvent(eventName, data) {
613
+ const event = {
614
+ name: eventName,
615
+ data,
616
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
617
+ };
618
+ for (const sessionId of this.sessionSockets.keys()) {
619
+ event.sessionId = sessionId;
620
+ this.broadcastToSession(sessionId, {
621
+ type: "event",
622
+ data: event
623
+ });
624
+ }
625
+ }
626
+ /**
627
+ * Process a custom event server-side (runs handlers, bridges, webhooks)
628
+ * Useful for server-side automation or triggering events programmatically
629
+ * @param sessionId - The session ID to associate with the event
630
+ * @param eventName - The name of the event
631
+ * @param data - Optional payload for the event
632
+ * @example
633
+ * // Trigger event from backend logic (e.g., after purchase)
634
+ * await pp.triggerEvent('session-123', 'purchase_completed', {
635
+ * orderId: 'order-456',
636
+ * amount: 99.99
637
+ * })
638
+ */
639
+ async triggerEvent(sessionId, eventName, data) {
640
+ const event = {
641
+ name: eventName,
642
+ data,
643
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
644
+ sessionId
645
+ };
646
+ await this.handleCustomEvent(sessionId, event);
647
+ }
648
+ // ─────────────────────────────────────────────────────────────────
424
649
  // Bridges
425
650
  // ─────────────────────────────────────────────────────────────────
426
651
  async notifyBridges(event, ...args) {
@@ -448,6 +673,86 @@ var PocketPing = class {
448
673
  }
449
674
  }
450
675
  }
676
+ async notifyBridgesEvent(event, session) {
677
+ for (const bridge of this.bridges) {
678
+ try {
679
+ await bridge.onEvent?.(event, session);
680
+ } catch (err) {
681
+ console.error(`[PocketPing] Bridge ${bridge.name} event notification error:`, err);
682
+ }
683
+ }
684
+ }
685
+ async notifyBridgesIdentity(session) {
686
+ for (const bridge of this.bridges) {
687
+ try {
688
+ await bridge.onIdentityUpdate?.(session);
689
+ } catch (err) {
690
+ console.error(`[PocketPing] Bridge ${bridge.name} identity notification error:`, err);
691
+ }
692
+ }
693
+ }
694
+ // ─────────────────────────────────────────────────────────────────
695
+ // Webhook Forwarding
696
+ // ─────────────────────────────────────────────────────────────────
697
+ /**
698
+ * Forward custom event to configured webhook URL (non-blocking)
699
+ * Used for integrations with Zapier, Make, n8n, or custom backends
700
+ */
701
+ forwardToWebhook(event, session) {
702
+ if (!this.config.webhookUrl) return;
703
+ const payload = {
704
+ event,
705
+ session: {
706
+ id: session.id,
707
+ visitorId: session.visitorId,
708
+ metadata: session.metadata,
709
+ identity: session.identity
710
+ },
711
+ sentAt: (/* @__PURE__ */ new Date()).toISOString()
712
+ };
713
+ const body = JSON.stringify(payload);
714
+ const headers = {
715
+ "Content-Type": "application/json"
716
+ };
717
+ if (this.config.webhookSecret) {
718
+ const signature = createHmac("sha256", this.config.webhookSecret).update(body).digest("hex");
719
+ headers["X-PocketPing-Signature"] = `sha256=${signature}`;
720
+ }
721
+ const timeout = this.config.webhookTimeout ?? 5e3;
722
+ const controller = new AbortController();
723
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
724
+ fetch(this.config.webhookUrl, {
725
+ method: "POST",
726
+ headers,
727
+ body,
728
+ signal: controller.signal
729
+ }).then((response) => {
730
+ clearTimeout(timeoutId);
731
+ if (!response.ok) {
732
+ console.error(`[PocketPing] Webhook returned ${response.status}: ${response.statusText}`);
733
+ }
734
+ }).catch((err) => {
735
+ clearTimeout(timeoutId);
736
+ if (err.name === "AbortError") {
737
+ console.error(`[PocketPing] Webhook timed out after ${timeout}ms`);
738
+ } else {
739
+ console.error(`[PocketPing] Webhook error:`, err.message);
740
+ }
741
+ });
742
+ }
743
+ /**
744
+ * Forward identity update to webhook as a special event
745
+ */
746
+ forwardIdentityToWebhook(session) {
747
+ if (!this.config.webhookUrl || !session.identity) return;
748
+ const event = {
749
+ name: "identify",
750
+ data: session.identity,
751
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
752
+ sessionId: session.id
753
+ };
754
+ this.forwardToWebhook(event, session);
755
+ }
451
756
  addBridge(bridge) {
452
757
  this.bridges.push(bridge);
453
758
  bridge.init?.(this);
@@ -461,6 +766,89 @@ var PocketPing = class {
461
766
  getStorage() {
462
767
  return this.storage;
463
768
  }
769
+ // ─────────────────────────────────────────────────────────────────
770
+ // Version Management
771
+ // ─────────────────────────────────────────────────────────────────
772
+ /**
773
+ * Check widget version against configured min/latest versions
774
+ * @param widgetVersion - Version from X-PocketPing-Version header
775
+ * @returns Version check result with status and headers to set
776
+ */
777
+ checkWidgetVersion(widgetVersion) {
778
+ if (!widgetVersion) {
779
+ return {
780
+ status: "ok",
781
+ canContinue: true
782
+ };
783
+ }
784
+ const { minWidgetVersion, latestWidgetVersion } = this.config;
785
+ if (!minWidgetVersion && !latestWidgetVersion) {
786
+ return {
787
+ status: "ok",
788
+ canContinue: true
789
+ };
790
+ }
791
+ let status = "ok";
792
+ let message;
793
+ let canContinue = true;
794
+ if (minWidgetVersion && compareVersions(widgetVersion, minWidgetVersion) < 0) {
795
+ status = "unsupported";
796
+ message = this.config.versionWarningMessage || `Widget version ${widgetVersion} is no longer supported. Minimum version: ${minWidgetVersion}`;
797
+ canContinue = false;
798
+ } else if (latestWidgetVersion && compareVersions(widgetVersion, latestWidgetVersion) < 0) {
799
+ const majorDiff = parseVersion(latestWidgetVersion)[0] - parseVersion(widgetVersion)[0];
800
+ if (majorDiff >= 1) {
801
+ status = "deprecated";
802
+ message = this.config.versionWarningMessage || `Widget version ${widgetVersion} is deprecated. Please update to ${latestWidgetVersion}`;
803
+ } else {
804
+ status = "outdated";
805
+ message = `A newer widget version ${latestWidgetVersion} is available`;
806
+ }
807
+ }
808
+ return {
809
+ status,
810
+ message,
811
+ minVersion: minWidgetVersion,
812
+ latestVersion: latestWidgetVersion,
813
+ canContinue
814
+ };
815
+ }
816
+ /**
817
+ * Set version warning headers on HTTP response
818
+ */
819
+ setVersionHeaders(res, versionCheck) {
820
+ if (versionCheck.status !== "ok") {
821
+ res.setHeader("X-PocketPing-Version-Status", versionCheck.status);
822
+ if (versionCheck.minVersion) {
823
+ res.setHeader("X-PocketPing-Min-Version", versionCheck.minVersion);
824
+ }
825
+ if (versionCheck.latestVersion) {
826
+ res.setHeader("X-PocketPing-Latest-Version", versionCheck.latestVersion);
827
+ }
828
+ if (versionCheck.message) {
829
+ res.setHeader("X-PocketPing-Version-Message", versionCheck.message);
830
+ }
831
+ }
832
+ }
833
+ /**
834
+ * Send version warning via WebSocket to a session
835
+ */
836
+ sendVersionWarning(sessionId, versionCheck) {
837
+ if (versionCheck.status === "ok") return;
838
+ this.broadcastToSession(sessionId, {
839
+ type: "version_warning",
840
+ data: {
841
+ severity: versionCheck.status === "unsupported" ? "error" : versionCheck.status === "deprecated" ? "warning" : "info",
842
+ message: versionCheck.message,
843
+ currentVersion: "unknown",
844
+ // Will be filled by widget
845
+ minVersion: versionCheck.minVersion,
846
+ latestVersion: versionCheck.latestVersion,
847
+ canContinue: versionCheck.canContinue,
848
+ upgradeUrl: this.config.versionUpgradeUrl || "https://docs.pocketping.io/widget/installation"
849
+ }
850
+ });
851
+ }
464
852
  };
465
853
  export {
466
854
  MemoryStorage,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pocketping/sdk-node",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Node.js SDK for implementing PocketPing protocol",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.mjs",
@@ -9,9 +9,12 @@
9
9
  "dist"
10
10
  ],
11
11
  "scripts": {
12
+ "clean": "rm -rf dist && find src -name '*.js' -o -name '*.js.map' -o -name '*.d.ts' -o -name '*.d.ts.map' | xargs rm -f 2>/dev/null || true",
13
+ "prebuild": "pnpm clean",
12
14
  "build": "tsup src/index.ts --format cjs,esm --dts",
13
15
  "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
14
- "test": "vitest"
16
+ "test": "vitest run",
17
+ "test:watch": "vitest"
15
18
  },
16
19
  "dependencies": {
17
20
  "ws": "^8.16.0"