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.
- package/README.md +57 -46
- package/chat.js +585 -0
- package/index.js +835 -43
- package/package.json +3 -2
- package/spaces.js +414 -0
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pingerchips-js",
|
|
3
|
-
"version": "
|
|
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": "
|
|
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;
|