pingerchips-js-server 2.0.0 → 2.1.1

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.
Files changed (3) hide show
  1. package/README.md +61 -47
  2. package/index.js +156 -86
  3. package/package.json +2 -4
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
- # Pingerchips Server SDK
1
+ # pingerchips-js-server
2
2
 
3
- Server-side SDK for triggering events and authenticating users with Pingerchips.
3
+ Server-side SDK for Pingerchips. Trigger events and authenticate users for private/presence channels.
4
+
5
+ Requires Node.js 18+ (uses native `fetch`).
4
6
 
5
7
  ## Installation
6
8
 
@@ -15,24 +17,23 @@ npm install pingerchips-js-server
15
17
  ```javascript
16
18
  import PingerchipsServer from 'pingerchips-js-server';
17
19
 
18
- const pingerchips = new PingerchipsServer('app_id', 'app_secret', {
19
- appKey: 'app_key',
20
- endpoint: 'https://pinger-processor.pingerchips.com/api'
21
- });
20
+ const pingerchips = new PingerchipsServer('app_key', 'app_secret');
22
21
  ```
23
22
 
24
- ### Trigger Events
23
+ Defaults to `https://queue.pingerchips.com` in production (`NODE_ENV=production`) and `http://localhost:4000` otherwise. Override with `options.endpoint` or the `PINGERCHIPS_API_ENDPOINT` env var.
25
24
 
26
- Send events to channels from your server:
25
+ ### Trigger events
27
26
 
28
27
  ```javascript
29
- await pingerchips.trigger('lobby', 'message', {
28
+ await pingerchips.trigger('lobby', 'new-message', {
30
29
  text: 'Hello from server!',
31
30
  timestamp: Date.now()
32
31
  });
33
32
  ```
34
33
 
35
- ### Authenticate Users (Private/Presence Channels)
34
+ Failed requests are retried on 5xx and network errors (default: 2 retries).
35
+
36
+ ### Authenticate users (private/presence channels)
36
37
 
37
38
  Implement an auth endpoint on your server:
38
39
 
@@ -45,76 +46,89 @@ app.use(express.json());
45
46
  app.post('/auth', (req, res) => {
46
47
  const { socket_id, channel_name, auth_info } = req.body;
47
48
 
48
- // Validate user from session/token
49
49
  const user = validateUser(auth_info);
50
- if (!user) {
51
- return res.status(403).json({ error: 'Unauthorized' });
52
- }
50
+ if (!user) return res.status(403).json({ error: 'Unauthorized' });
53
51
 
54
- // For presence channels, provide user data
55
- const userData = channel_name.startsWith('presence-') ? {
56
- user_id: user.id,
57
- user_info: {
58
- name: user.name,
59
- avatar: user.avatar
60
- }
61
- } : null;
52
+ const userData = channel_name.startsWith('presence-')
53
+ ? { user_id: user.id, user_info: { name: user.name } }
54
+ : null;
62
55
 
63
- // Sign authentication with Pingerchips
64
56
  const authData = pingerchips.authenticate(socket_id, channel_name, userData);
65
57
  res.json(authData);
66
58
  });
67
59
  ```
68
60
 
69
- ## API Reference
61
+ Configure the client to use it:
62
+
63
+ ```javascript
64
+ const client = new Pingerchips('app_key', {
65
+ authEndpoint: 'https://your-server.com/auth'
66
+ });
67
+ ```
68
+
69
+ ## API
70
70
 
71
- ### `new PingerchipsServer(appId, appSecret, options)`
71
+ ### `new PingerchipsServer(appKey, appSecret, options?)`
72
72
 
73
- Create a new server instance.
73
+ | Parameter | Type | Description |
74
+ |---|---|---|
75
+ | `appKey` | string | Your app key |
76
+ | `appSecret` | string | Your app secret — used for HMAC signing, never sent over the wire |
74
77
 
75
78
  **Options:**
76
- - `appKey` - Your app key (required for authentication)
77
- - `endpoint` - API endpoint URL
78
- - `token` - API token for internal endpoints
79
- - `mtls` - mTLS configuration for secure connections
79
+
80
+ | Option | Type | Default | Description |
81
+ |---|---|---|---|
82
+ | `endpoint` | string | auto | API base URL |
83
+ | `requestTimeout` | number | `10000` | Request timeout in ms |
84
+ | `retries` | number | `2` | Retries on 5xx / network error |
85
+ | `token` | string | — | Internal API token (legacy) |
86
+ | `mtls` | object | — | mTLS config (see below) |
80
87
 
81
88
  ### `trigger(channel, event, data)`
82
89
 
83
- Send an event to a channel.
90
+ Trigger an event on a channel.
91
+
92
+ - `channel` — max 200 chars
93
+ - `event` — max 200 chars
94
+ - `data` — must be JSON serializable, max 64KB
84
95
 
85
96
  ```javascript
86
- await pingerchips.trigger('my-channel', 'my-event', { message: 'Hello' });
97
+ await pingerchips.trigger('room-42', 'message', { text: 'hi' });
87
98
  ```
88
99
 
89
100
  ### `authenticate(socketId, channelName, userData?)`
90
101
 
91
- Generate signed authentication for private/presence channels.
102
+ Sign a channel auth request. Call from your auth endpoint.
92
103
 
93
- **Parameters:**
94
- - `socketId` - Socket ID from client
95
- - `channelName` - Channel name (e.g., "private-chat" or "presence-lobby")
96
- - `userData` - User data for presence channels (must include `user_id`)
104
+ - `channelName` must start with `private-` or `presence-`
105
+ - `userData` required for presence channels, must include `user_id`
97
106
 
98
- **Returns:**
99
107
  ```javascript
100
- {
101
- auth: "app_key:hmac_signature",
102
- channel_data: "{\"user_id\":\"123\",...}" // presence channels only
103
- }
108
+ // Private channel
109
+ const auth = pingerchips.authenticate(socketId, 'private-chat');
110
+ // → { auth: "app_key:hmac_signature" }
111
+
112
+ // Presence channel
113
+ const auth = pingerchips.authenticate(socketId, 'presence-lobby', {
114
+ user_id: '123',
115
+ user_info: { name: 'Alice' }
116
+ });
117
+ // → { auth: "app_key:hmac_signature", channel_data: "{\"user_id\":\"123\",...}" }
104
118
  ```
105
119
 
106
- ## mTLS Support
120
+ ## mTLS
107
121
 
108
122
  For secure server-to-server communication:
109
123
 
110
124
  ```javascript
111
- const pingerchips = new PingerchipsServer('app_id', 'app_secret', {
112
- endpoint: 'https://pinger-processor.pingerchips.com/api',
125
+ const pingerchips = new PingerchipsServer('app_key', 'app_secret', {
113
126
  mtls: {
114
127
  enabled: true,
115
- cert: '/path/to/client-cert.pem',
128
+ cert: '/path/to/client-cert.pem', // or PEM string directly
116
129
  key: '/path/to/client-key.pem',
117
- ca: '/path/to/ca-cert.pem'
130
+ ca: '/path/to/ca-cert.pem',
131
+ rejectUnauthorized: true // default — do not disable
118
132
  }
119
133
  });
120
134
  ```
package/index.js CHANGED
@@ -3,164 +3,234 @@ import https from "https";
3
3
  import crypto from "crypto";
4
4
 
5
5
  class PingerchipsServer {
6
- constructor(appId, appSecret, options = {}) {
7
- this.appId = appId;
6
+ /**
7
+ * @param {string} appKey - App key (used for signing and routing)
8
+ * @param {string} appSecret - App secret (used for HMAC signing)
9
+ * @param {object} options
10
+ */
11
+ constructor(appKey, appSecret, options = {}) {
12
+ this.appKey = appKey;
8
13
  this.appSecret = appSecret;
9
- this.appKey = options.appKey || appId; // App key for signing
10
14
  this.endpoint =
11
15
  options.endpoint ||
12
16
  process.env.PINGERCHIPS_API_ENDPOINT ||
13
- process.env.NODE_ENV === "production"
14
- ? "https://pinger-processor.pingerchips.com"
15
- : "http://localhost:4000";
17
+ (process.env.NODE_ENV === "production"
18
+ ? "https://queue.pingerchips.com"
19
+ : "http://localhost:4000");
16
20
  this.token = options.token;
21
+ this.requestTimeout = options.requestTimeout || 10000;
22
+ this.retries = options.retries ?? 2; // retry 5xx up to N times
17
23
 
18
- // mTLS configuration
19
24
  this.mtls = {
20
25
  enabled: options.mtls?.enabled || false,
21
26
  cert: options.mtls?.cert,
22
27
  key: options.mtls?.key,
23
28
  ca: options.mtls?.ca,
24
- rejectUnauthorized: options.mtls?.rejectUnauthorized !== false,
29
+ rejectUnauthorized: options.mtls?.rejectUnauthorized ?? true,
25
30
  };
26
-
27
- this.fetchPromise = import("node-fetch").then((mod) => mod.default);
28
31
  }
29
32
 
30
33
  _createHttpsAgent() {
31
- if (!this.mtls.enabled) {
32
- return undefined;
33
- }
34
+ if (!this.mtls.enabled) return undefined;
34
35
 
35
36
  const agentOptions = {
36
37
  rejectUnauthorized: this.mtls.rejectUnauthorized,
37
38
  };
38
39
 
39
- // Load cert, key, and ca - support both file paths and direct content
40
- if (this.mtls.cert) {
41
- agentOptions.cert = this._loadCertificate(this.mtls.cert);
42
- }
43
- if (this.mtls.key) {
44
- agentOptions.key = this._loadCertificate(this.mtls.key);
45
- }
46
- if (this.mtls.ca) {
47
- agentOptions.ca = this._loadCertificate(this.mtls.ca);
48
- }
40
+ if (this.mtls.cert) agentOptions.cert = this._loadCertificate(this.mtls.cert);
41
+ if (this.mtls.key) agentOptions.key = this._loadCertificate(this.mtls.key);
42
+ if (this.mtls.ca) agentOptions.ca = this._loadCertificate(this.mtls.ca);
49
43
 
50
44
  return new https.Agent(agentOptions);
51
45
  }
52
46
 
53
47
  _loadCertificate(certOrPath) {
54
- // If it looks like a certificate/key content (starts with -----), return as-is
55
- if (
56
- typeof certOrPath === "string" &&
57
- certOrPath.trim().startsWith("-----")
58
- ) {
48
+ if (typeof certOrPath === "string" && certOrPath.trim().startsWith("-----")) {
59
49
  return certOrPath;
60
50
  }
61
- // Otherwise, treat as file path
62
51
  return fs.readFileSync(certOrPath, "utf8");
63
52
  }
64
53
 
54
+ _validateTriggerInput(channel, event, data) {
55
+ if (!channel || typeof channel !== "string") {
56
+ throw new TypeError("channel must be a non-empty string");
57
+ }
58
+ if (!event || typeof event !== "string") {
59
+ throw new TypeError("event must be a non-empty string");
60
+ }
61
+ if (channel.length > 200) {
62
+ throw new Error("channel name must be 200 characters or less");
63
+ }
64
+ if (event.length > 200) {
65
+ throw new Error("event name must be 200 characters or less");
66
+ }
67
+ try {
68
+ const serialized = JSON.stringify(data);
69
+ if (serialized.length > 65536) {
70
+ throw new Error("data payload must be 64KB or less");
71
+ }
72
+ } catch {
73
+ throw new TypeError("data must be JSON serializable");
74
+ }
75
+ }
76
+
77
+ _generateSignature(method, path, body) {
78
+ const timestamp = Math.floor(Date.now() / 1000);
79
+ const bodyString = JSON.stringify(body);
80
+ const stringToSign = `${method}\n${path}\n${timestamp}\n${bodyString}`;
81
+
82
+ const signature = crypto
83
+ .createHmac("sha256", this.appSecret)
84
+ .update(stringToSign)
85
+ .digest("hex");
86
+
87
+ return { signature, timestamp };
88
+ }
89
+
90
+ async _fetchWithRetry(url, requestOptions, retriesLeft) {
91
+ const controller = new AbortController();
92
+ const timeoutId = setTimeout(() => controller.abort(), this.requestTimeout);
93
+
94
+ try {
95
+ const response = await fetch(url, {
96
+ ...requestOptions,
97
+ signal: controller.signal,
98
+ });
99
+
100
+ // Retry on 5xx if retries remain
101
+ if (response.status >= 500 && retriesLeft > 0) {
102
+ return this._fetchWithRetry(url, requestOptions, retriesLeft - 1);
103
+ }
104
+
105
+ return response;
106
+ } catch (err) {
107
+ if (err.name === "AbortError") {
108
+ throw new Error(`Request timeout after ${this.requestTimeout}ms`);
109
+ }
110
+ // Retry on network errors
111
+ if (retriesLeft > 0) {
112
+ return this._fetchWithRetry(url, requestOptions, retriesLeft - 1);
113
+ }
114
+ throw err;
115
+ } finally {
116
+ clearTimeout(timeoutId);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Trigger an event on a channel.
122
+ * Uses HMAC-SHA256 signature authentication.
123
+ *
124
+ * @param {string} channel
125
+ * @param {string} event
126
+ * @param {any} data - Must be JSON serializable, max 64KB
127
+ * @returns {Promise<object>}
128
+ */
65
129
  async trigger(channel, event, data) {
66
- const fetch = await this.fetchPromise;
67
- const url = `${this.endpoint}/apps/${this.appId}/trigger`;
130
+ this._validateTriggerInput(channel, event, data);
131
+
132
+ const path = `/api/apps/${this.appKey}/trigger`;
133
+ const url = `${this.endpoint}${path}`;
134
+ const body = { channel, event, data };
135
+ const { signature, timestamp } = this._generateSignature("POST", path, body);
68
136
 
69
137
  const requestOptions = {
70
138
  method: "POST",
71
139
  headers: {
72
140
  "Content-Type": "application/json",
73
- token: this.token,
141
+ "X-App-Key": this.appKey,
142
+ "X-Signature": signature,
143
+ "X-Timestamp": timestamp.toString(),
74
144
  },
75
- body: JSON.stringify({
76
- app_id: this.appId,
77
- app_secret: this.appSecret,
78
- channel,
79
- event,
80
- data,
81
- }),
145
+ body: JSON.stringify(body),
82
146
  };
83
147
 
84
- // Add HTTPS agent if mTLS is enabled
148
+ if (this.token) {
149
+ requestOptions.headers["token"] = this.token;
150
+ }
151
+
85
152
  if (this.mtls.enabled) {
86
153
  requestOptions.agent = this._createHttpsAgent();
87
154
  }
88
155
 
89
- const response = await fetch(url, requestOptions);
156
+ const response = await this._fetchWithRetry(url, requestOptions, this.retries);
90
157
 
91
158
  if (!response.ok) {
92
159
  const error = await response.json().catch(() => ({}));
93
160
  throw new Error(
94
- `Failed to trigger event: ${response.statusText} - ${error.error || ""}`
161
+ `Failed to trigger event: ${response.status} ${response.statusText} - ${error.error || ""}`,
95
162
  );
96
163
  }
97
164
 
98
- return await response.json();
165
+ return response.json();
99
166
  }
100
167
 
101
168
  /**
102
- * Authenticate a user for a private or presence channel
103
- *
104
- * This method is called by your auth endpoint to sign user data.
105
- * The signature proves that your server authorized the user to join the channel.
169
+ * Authenticate a user for a private or presence channel.
170
+ * Call this from your auth endpoint.
106
171
  *
107
172
  * @param {string} socketId - Socket ID from the client SDK
108
- * @param {string} channelName - Channel name (e.g., "private-chat" or "presence-lobby")
109
- * @param {object} userData - User data for presence channels (must include user_id)
110
- * @returns {object} - Signed authentication data: { auth, channel_data? }
111
- *
112
- * @example
113
- * // In your Express.js auth endpoint:
114
- * app.post('/auth', (req, res) => {
115
- * const { socket_id, channel_name, auth_info } = req.body;
116
- *
117
- * // Validate user from session/token
118
- * const user = req.session.user;
119
- * if (!user) return res.status(403).json({ error: 'Unauthorized' });
120
- *
121
- * // For presence channels, provide user data
122
- * const userData = channel_name.startsWith('presence-') ? {
123
- * user_id: user.id,
124
- * user_info: { name: user.name, avatar: user.avatar }
125
- * } : null;
126
- *
127
- * const authData = pingerchips.authenticate(socket_id, channel_name, userData);
128
- * res.json(authData);
129
- * });
173
+ * @param {string} channelName - Must start with "private-" or "presence-"
174
+ * @param {object|null} userData - Required for presence channels, must include user_id
175
+ * @returns {{ auth: string, channel_data?: string, user_data?: string }}
130
176
  */
131
177
  authenticate(socketId, channelName, userData = null) {
132
- // Build the full topic name
133
- const fullTopic = `app:${this.appKey}:room:${channelName}`;
178
+ if (!socketId || typeof socketId !== "string") {
179
+ throw new TypeError("socketId must be a non-empty string");
180
+ }
181
+ if (!channelName || typeof channelName !== "string") {
182
+ throw new TypeError("channelName must be a non-empty string");
183
+ }
134
184
 
135
- // Build string to sign based on channel type
185
+ const isPrivate = channelName.startsWith("private-");
186
+ const isPresence = channelName.startsWith("presence-");
187
+
188
+ if (!isPrivate && !isPresence) {
189
+ throw new Error(
190
+ "Public channels do not require authentication. Only private-* and presence-* channels need auth.",
191
+ );
192
+ }
193
+
194
+ if (isPresence && !userData) {
195
+ throw new Error(
196
+ "Presence channels require userData with at least user_id field",
197
+ );
198
+ }
199
+
200
+ if (userData) {
201
+ if (typeof userData !== "object" || userData === null) {
202
+ throw new TypeError("userData must be an object");
203
+ }
204
+ if (isPresence && !userData.user_id) {
205
+ throw new Error("userData must include user_id for presence channels");
206
+ }
207
+ }
208
+
209
+ const fullTopic = `app:${this.appKey}:room:${channelName}`;
210
+ let userDataString = null;
136
211
  let stringToSign;
137
- let channelData = null;
138
212
 
139
- if (userData && channelName.startsWith("presence-")) {
140
- // Presence channel: include user data in signature
141
- channelData = JSON.stringify(userData);
142
- stringToSign = `${socketId}:${fullTopic}:${channelData}`;
213
+ if (userData) {
214
+ userDataString = JSON.stringify(userData);
215
+ stringToSign = `${socketId}:${fullTopic}:${userDataString}`;
143
216
  } else {
144
- // Private channel: just socket_id and topic
145
217
  stringToSign = `${socketId}:${fullTopic}`;
146
218
  }
147
219
 
148
- // Generate HMAC-SHA256 signature using app_secret
149
220
  const signature = crypto
150
221
  .createHmac("sha256", this.appSecret)
151
222
  .update(stringToSign)
152
223
  .digest("hex");
153
224
 
154
- const auth = `${this.appKey}:${signature}`;
155
-
156
- // Build response
157
- const response = { auth };
225
+ const result = { auth: `${this.appKey}:${signature}` };
158
226
 
159
- if (channelData) {
160
- response.channel_data = channelData;
227
+ if (isPresence && userDataString) {
228
+ result.channel_data = userDataString;
229
+ } else if (userDataString) {
230
+ result.user_data = userDataString;
161
231
  }
162
232
 
163
- return response;
233
+ return result;
164
234
  }
165
235
  }
166
236
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pingerchips-js-server",
3
- "version": "2.0.0",
3
+ "version": "2.1.1",
4
4
  "description": "Pingerchips server SDK for Node.js - trigger events and authenticate users",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -17,9 +17,7 @@
17
17
  ],
18
18
  "author": "Pingerchips",
19
19
  "license": "MIT",
20
- "dependencies": {
21
- "node-fetch": "^3.3.0"
22
- },
20
+ "dependencies": {},
23
21
  "repository": {
24
22
  "type": "git",
25
23
  "url": "https://github.com/pingerchips/pingerchips-js-server"