pingerchips-js 2.1.0 → 3.0.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.
Files changed (5) hide show
  1. package/README.md +57 -46
  2. package/chat.js +585 -0
  3. package/index.js +835 -43
  4. package/package.json +3 -2
  5. package/spaces.js +414 -0
package/package.json CHANGED
@@ -1,11 +1,11 @@
1
1
  {
2
2
  "name": "pingerchips-js",
3
- "version": "2.1.0",
3
+ "version": "3.0.0",
4
4
  "description": "Pingerchips JavaScript client SDK for real-time WebSocket connections",
5
5
  "main": "index.js",
6
6
  "type": "module",
7
7
  "scripts": {
8
- "test": "echo \"Error: no test specified\" && exit 1"
8
+ "test": "node --test test/*.test.js"
9
9
  },
10
10
  "keywords": [
11
11
  "pingerchips",
@@ -18,6 +18,7 @@
18
18
  "author": "Pingerchips",
19
19
  "license": "MIT",
20
20
  "dependencies": {
21
+ "@msgpack/msgpack": "^3.1.2",
21
22
  "phoenix": "^1.7.0"
22
23
  },
23
24
  "repository": {
package/spaces.js ADDED
@@ -0,0 +1,414 @@
1
+ import { Pingerchips } from "./index.js";
2
+
3
+ // ---------------------------------------------------------------------------
4
+ // PingerchipsSpaces — entry point
5
+ // ---------------------------------------------------------------------------
6
+
7
+ /**
8
+ * Usage:
9
+ * const spaces = new PingerchipsSpaces('pk_live_...', { endpoint: 'wss://...' });
10
+ * await spaces.connect();
11
+ * const space = await spaces.get('doc-abc123', { clientId: 'user-42', profile: { name: 'Alice' } });
12
+ *
13
+ * space.cursors.set({ x: 124, y: 88 });
14
+ * space.cursors.subscribe('update', ({ member, position }) => renderCursor(member, position));
15
+ *
16
+ * await space.enter({ name: 'Alice', color: '#FF0099' });
17
+ * space.members.subscribe('enter', (member) => addAvatar(member));
18
+ *
19
+ * space.locations.set({ elementId: 'heading-2' });
20
+ * space.locations.subscribe('update', ({ member, location }) => showIndicator(member, location));
21
+ *
22
+ * const lock = await space.locks.acquire('block-3');
23
+ * await space.locks.release('block-3');
24
+ *
25
+ * await space.leave();
26
+ */
27
+ export class PingerchipsSpaces {
28
+ constructor(appKey, options = {}) {
29
+ if (!appKey) throw new Error("appKey required");
30
+ this.appKey = appKey;
31
+ this._options = options;
32
+ this._realtime =
33
+ options.realtime || new Pingerchips(appKey, { endpoint: options.endpoint });
34
+ this._spaces = {};
35
+ }
36
+
37
+ /**
38
+ * Waits until the socket is connected.
39
+ * @returns {Promise<void>}
40
+ */
41
+ connect() {
42
+ return new Promise((resolve) => {
43
+ if (this._realtime.socket?.isConnected()) return resolve();
44
+ this._realtime.socket.onOpen(() => resolve());
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Join or retrieve a named Space.
50
+ *
51
+ * @param {string} spaceId - Unique space name, e.g. "doc-abc123"
52
+ * @param {{ clientId: string, profile?: object, throttle?: number }} options
53
+ * @returns {Promise<Space>}
54
+ */
55
+ async get(spaceId, { clientId, profile = {}, throttle = 33 } = {}) {
56
+ if (!clientId) throw new Error("clientId required");
57
+ const key = spaceId;
58
+ if (this._spaces[key]) return this._spaces[key];
59
+
60
+ const topic = `app:${this.appKey}:room:ephemeral-${spaceId}`;
61
+ const channel = this._realtime.socket.channel(topic, {});
62
+
63
+ const space = new Space(channel, spaceId, clientId, profile, throttle);
64
+ await space._join();
65
+ this._spaces[key] = space;
66
+ return space;
67
+ }
68
+
69
+ /**
70
+ * Leave and clean up a space.
71
+ * @param {string} spaceId
72
+ */
73
+ leave(spaceId) {
74
+ const space = this._spaces[spaceId];
75
+ if (space) {
76
+ space._leave();
77
+ delete this._spaces[spaceId];
78
+ }
79
+ }
80
+ }
81
+
82
+ // ---------------------------------------------------------------------------
83
+ // Space
84
+ // ---------------------------------------------------------------------------
85
+
86
+ class Space {
87
+ constructor(channel, spaceId, clientId, profile, throttle) {
88
+ this._channel = channel;
89
+ this.spaceId = spaceId;
90
+ this.clientId = clientId;
91
+ this.profile = profile;
92
+ this._throttleMs = throttle;
93
+
94
+ this.cursors = new Cursors(this);
95
+ this.members = new Members(this);
96
+ this.locations = new Locations(this);
97
+ this.locks = new Locks(this);
98
+
99
+ // Wire up server events to sub-APIs
100
+ channel.on("presence:state", (payload) => {
101
+ this.members._handleState(payload.members || []);
102
+ });
103
+ channel.on("presence:join", (payload) => {
104
+ this.members._handleJoin(payload);
105
+ });
106
+ channel.on("presence:leave", (payload) => {
107
+ this.members._handleLeave(payload);
108
+ });
109
+ channel.on("cursor", (payload) => {
110
+ this.cursors._handleUpdate(payload);
111
+ });
112
+ channel.on("location", (payload) => {
113
+ this.locations._handleUpdate(payload);
114
+ });
115
+ channel.on("lock:update", (payload) => {
116
+ this.locks._handleUpdate(payload);
117
+ });
118
+ }
119
+
120
+ _join() {
121
+ return new Promise((resolve, reject) => {
122
+ this._channel
123
+ .join()
124
+ .receive("ok", () => resolve())
125
+ .receive("error", (err) => reject(new Error(`Space join failed: ${JSON.stringify(err)}`)));
126
+ });
127
+ }
128
+
129
+ /**
130
+ * Enter the space with a member profile. Broadcasts presence to all members.
131
+ * @param {object} profile - Arbitrary member metadata: { name, color, avatar, ... }
132
+ */
133
+ async enter(profile = {}) {
134
+ this.profile = { ...this.profile, ...profile };
135
+ this._channel.push("presence:update", { profile: this.profile, client_id: this.clientId });
136
+ }
137
+
138
+ /**
139
+ * Leave the space. Releases all held locks and removes presence.
140
+ */
141
+ async leave() {
142
+ this._channel.push("presence:leave", { client_id: this.clientId });
143
+ this._leave();
144
+ }
145
+
146
+ _leave() {
147
+ this._channel.leave();
148
+ }
149
+ }
150
+
151
+ // ---------------------------------------------------------------------------
152
+ // Cursors
153
+ // ---------------------------------------------------------------------------
154
+
155
+ class Cursors {
156
+ constructor(space) {
157
+ this._space = space;
158
+ this._handlers = [];
159
+ this._throttleTimer = null;
160
+ this._pendingPosition = null;
161
+ }
162
+
163
+ /**
164
+ * Publish the current cursor position. Client-side throttled.
165
+ * @param {{ x: number, y: number, [extra: string]: any }} position
166
+ */
167
+ set(position) {
168
+ this._pendingPosition = position;
169
+ if (this._throttleTimer) return;
170
+ this._throttleTimer = setTimeout(() => {
171
+ this._throttleTimer = null;
172
+ if (this._pendingPosition) {
173
+ this._space._channel.push("cursor", {
174
+ client_id: this._space.clientId,
175
+ position: this._pendingPosition,
176
+ });
177
+ this._pendingPosition = null;
178
+ }
179
+ }, this._space._throttleMs);
180
+ }
181
+
182
+ /**
183
+ * Subscribe to cursor updates from all other members.
184
+ * @param {'update'} event
185
+ * @param {function} handler - ({ member: { clientId, profile }, position }) => void
186
+ * @returns {() => void} unsubscribe function
187
+ */
188
+ subscribe(event, handler) {
189
+ if (event !== "update") return () => {};
190
+ this._handlers.push(handler);
191
+ return () => {
192
+ this._handlers = this._handlers.filter((h) => h !== handler);
193
+ };
194
+ }
195
+
196
+ _handleUpdate({ client_id, position }) {
197
+ if (client_id === this._space.clientId) return; // skip own events
198
+ const member = this._space.members.get(client_id);
199
+ for (const h of this._handlers) h({ member, position });
200
+ }
201
+ }
202
+
203
+ // ---------------------------------------------------------------------------
204
+ // Members
205
+ // ---------------------------------------------------------------------------
206
+
207
+ class Members {
208
+ constructor(space) {
209
+ this._space = space;
210
+ this._members = {}; // clientId → member object
211
+ this._handlers = { enter: [], leave: [], update: [] };
212
+ }
213
+
214
+ /**
215
+ * Get the current member list.
216
+ * @returns {object[]}
217
+ */
218
+ getAll() {
219
+ return Object.values(this._members);
220
+ }
221
+
222
+ /**
223
+ * Get a member by clientId.
224
+ * @param {string} clientId
225
+ * @returns {object|undefined}
226
+ */
227
+ get(clientId) {
228
+ return this._members[clientId];
229
+ }
230
+
231
+ /**
232
+ * Subscribe to member lifecycle events.
233
+ * @param {'enter'|'leave'|'update'} event
234
+ * @param {function} handler
235
+ * @returns {() => void} unsubscribe function
236
+ */
237
+ subscribe(event, handler) {
238
+ if (!this._handlers[event]) return () => {};
239
+ this._handlers[event].push(handler);
240
+ return () => {
241
+ this._handlers[event] = this._handlers[event].filter((h) => h !== handler);
242
+ };
243
+ }
244
+
245
+ _handleState(members) {
246
+ this._members = {};
247
+ for (const m of members) {
248
+ this._members[m.client_id || m.id] = _normaliseMember(m);
249
+ }
250
+ }
251
+
252
+ _handleJoin(payload) {
253
+ const member = _normaliseMember(payload);
254
+ const isUpdate = !!this._members[member.clientId];
255
+ this._members[member.clientId] = member;
256
+ const event = isUpdate ? "update" : "enter";
257
+ for (const h of this._handlers[event] || []) h(member);
258
+ }
259
+
260
+ _handleLeave(payload) {
261
+ const clientId = payload.client_id || payload.id;
262
+ const member = this._members[clientId];
263
+ delete this._members[clientId];
264
+ if (member) {
265
+ for (const h of this._handlers.leave) h(member);
266
+ }
267
+ }
268
+ }
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // Locations
272
+ // ---------------------------------------------------------------------------
273
+
274
+ class Locations {
275
+ constructor(space) {
276
+ this._space = space;
277
+ this._handlers = [];
278
+ this._current = null;
279
+ }
280
+
281
+ /**
282
+ * Set the current member's location (what they're looking at).
283
+ * @param {object} location - e.g. { elementId: 'heading-2', range: { start: 4, end: 12 } }
284
+ */
285
+ set(location) {
286
+ this._current = location;
287
+ this._space._channel.push("location", {
288
+ client_id: this._space.clientId,
289
+ location,
290
+ });
291
+ }
292
+
293
+ /**
294
+ * Subscribe to location updates from other members.
295
+ * @param {'update'} event
296
+ * @param {function} handler - ({ member, currentLocation, previousLocation }) => void
297
+ * @returns {() => void}
298
+ */
299
+ subscribe(event, handler) {
300
+ if (event !== "update") return () => {};
301
+ this._handlers.push(handler);
302
+ return () => {
303
+ this._handlers = this._handlers.filter((h) => h !== handler);
304
+ };
305
+ }
306
+
307
+ _handleUpdate({ client_id, location }) {
308
+ if (client_id === this._space.clientId) return;
309
+ const member = this._space.members.get(client_id);
310
+ const previous = member?._location ?? null;
311
+ if (member) member._location = location;
312
+ for (const h of this._handlers) h({ member, currentLocation: location, previousLocation: previous });
313
+ }
314
+ }
315
+
316
+ // ---------------------------------------------------------------------------
317
+ // Locks
318
+ // ---------------------------------------------------------------------------
319
+
320
+ class Locks {
321
+ constructor(space) {
322
+ this._space = space;
323
+ this._locks = {}; // lockId → { id, status, holder }
324
+ this._handlers = [];
325
+ }
326
+
327
+ /**
328
+ * Acquire a named lock.
329
+ * @param {string} lockId
330
+ * @returns {Promise<{ id: string, status: 'locked'|'pending' }>}
331
+ */
332
+ acquire(lockId) {
333
+ return new Promise((resolve, reject) => {
334
+ this._space._channel
335
+ .push("lock:acquire", { id: lockId })
336
+ .receive("ok", (resp) => {
337
+ this._locks[lockId] = { id: lockId, status: resp.status, holder: this._space.clientId };
338
+ resolve({ id: lockId, status: resp.status });
339
+ })
340
+ .receive("error", (err) => {
341
+ reject(new Error(err.reason || "lock held by another member"));
342
+ });
343
+ });
344
+ }
345
+
346
+ /**
347
+ * Release a named lock.
348
+ * @param {string} lockId
349
+ * @returns {Promise<void>}
350
+ */
351
+ release(lockId) {
352
+ return new Promise((resolve) => {
353
+ this._space._channel
354
+ .push("lock:release", { id: lockId })
355
+ .receive("ok", () => {
356
+ delete this._locks[lockId];
357
+ resolve();
358
+ })
359
+ .receive("error", () => resolve()); // no-op if not held
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Get all currently known locks.
365
+ * @returns {object[]}
366
+ */
367
+ getAll() {
368
+ return Object.values(this._locks);
369
+ }
370
+
371
+ /**
372
+ * Get a single lock by id.
373
+ * @param {string} lockId
374
+ * @returns {object|undefined}
375
+ */
376
+ get(lockId) {
377
+ return this._locks[lockId];
378
+ }
379
+
380
+ /**
381
+ * Subscribe to lock state changes.
382
+ * @param {'update'} event
383
+ * @param {function} handler - ({ id, status, member }) => void
384
+ * @returns {() => void}
385
+ */
386
+ subscribe(event, handler) {
387
+ if (event !== "update") return () => {};
388
+ this._handlers.push(handler);
389
+ return () => {
390
+ this._handlers = this._handlers.filter((h) => h !== handler);
391
+ };
392
+ }
393
+
394
+ _handleUpdate({ id, status, holder }) {
395
+ this._locks[id] = { id, status, holder };
396
+ if (status === "unlocked") delete this._locks[id];
397
+ const member = holder ? this._space.members.get(holder) : null;
398
+ for (const h of this._handlers) h({ id, status, member });
399
+ }
400
+ }
401
+
402
+ // ---------------------------------------------------------------------------
403
+ // Helpers
404
+ // ---------------------------------------------------------------------------
405
+
406
+ function _normaliseMember(raw) {
407
+ return {
408
+ clientId: raw.client_id || raw.id,
409
+ profile: raw.profile || {},
410
+ joinedAt: raw.joined_at || null,
411
+ };
412
+ }
413
+
414
+ export default PingerchipsSpaces;