pushi-js 0.5.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/package.json +52 -0
  2. package/pushi.js +1183 -0
  3. package/pushi.mjs +12 -0
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "pushi-js",
3
+ "version": "0.5.1",
4
+ "description": "JavaScript client for Hive Pushi push notification system",
5
+ "main": "pushi.js",
6
+ "browser": "pushi.js",
7
+ "module": "pushi.mjs",
8
+ "exports": {
9
+ ".": {
10
+ "import": "./pushi.mjs",
11
+ "require": "./pushi.js",
12
+ "browser": "./pushi.js",
13
+ "default": "./pushi.js"
14
+ }
15
+ },
16
+ "files": [
17
+ "pushi.js",
18
+ "pushi.mjs"
19
+ ],
20
+ "scripts": {
21
+ "lint": "eslint pushi.js",
22
+ "lint-fix": "eslint pushi.js --fix",
23
+ "test": "mocha --recursive test/"
24
+ },
25
+ "keywords": [
26
+ "pushi",
27
+ "websocket",
28
+ "push",
29
+ "notifications",
30
+ "realtime",
31
+ "pubsub",
32
+ "pusher"
33
+ ],
34
+ "author": "Hive Solutions <development@hive.pt>",
35
+ "license": "Apache-2.0",
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "git+https://github.com/hivesolutions/pushi.git"
39
+ },
40
+ "homepage": "https://github.com/hivesolutions/pushi#readme",
41
+ "bugs": {
42
+ "url": "https://github.com/hivesolutions/pushi/issues"
43
+ },
44
+ "devDependencies": {
45
+ "eslint": "^9.17.0",
46
+ "eslint-config-hive": "^0.7.0",
47
+ "mocha": "^10.8.2"
48
+ },
49
+ "engines": {
50
+ "node": ">=18"
51
+ }
52
+ }
package/pushi.js ADDED
@@ -0,0 +1,1183 @@
1
+ // Hive Pushi System
2
+ // Copyright (c) 2008-2024 Hive Solutions Lda.
3
+ //
4
+ // This file is part of Hive Pushi System.
5
+ //
6
+ // Hive Pushi System is free software: you can redistribute it and/or modify
7
+ // it under the terms of the Apache License as published by the Apache
8
+ // Foundation, either version 2.0 of the License, or (at your option) any
9
+ // later version.
10
+ //
11
+ // Hive Pushi System is distributed in the hope that it will be useful,
12
+ // but WITHOUT ANY WARRANTY; without even the implied warranty of
13
+ // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14
+ // Apache License for more details.
15
+ //
16
+ // You should have received a copy of the Apache License along with
17
+ // Hive Pushi System. If not, see <http://www.apache.org/licenses/>.
18
+
19
+ // __author__ = João Magalhães <joamag@hive.pt>
20
+ // __copyright__ = Copyright (c) 2008-2024 Hive Solutions Lda.
21
+ // __license__ = Apache License, Version 2.0
22
+
23
+ var PUSHI_CONNECTIONS = {};
24
+
25
+ // ===========================================
26
+ // Observable Implementation
27
+ // ===========================================
28
+
29
+ var Observable = function() {
30
+ this.events = {};
31
+ };
32
+
33
+ Observable.prototype.trigger = function(event) {
34
+ var index = 0;
35
+ var oneshots = null;
36
+ var methods = this.events[event] || [];
37
+ for (index = 0; index < methods.length; index++) {
38
+ var method = methods[index];
39
+ method.apply(this, arguments);
40
+ if (method.oneshot === false) {
41
+ continue;
42
+ }
43
+ oneshots = oneshots === null ? [] : oneshots;
44
+ oneshots.push(method);
45
+ }
46
+ if (oneshots === null) {
47
+ return;
48
+ }
49
+ for (index = 0; index < oneshots.length; index++) {
50
+ var oneshot = oneshots[index];
51
+ this.unbind(event, oneshot);
52
+ }
53
+ };
54
+
55
+ Observable.prototype.bind = function(event, method, oneshot) {
56
+ method.oneshot = Boolean(oneshot);
57
+ var methods = this.events[event] || [];
58
+ methods.push(method);
59
+ this.events[event] = methods;
60
+ };
61
+
62
+ Observable.prototype.unbind = function(event, method) {
63
+ var methods = this.events[event] || [];
64
+ var index = methods.indexOf(method);
65
+ index !== -1 && methods.splice(index, 1);
66
+ };
67
+
68
+ // ===========================================
69
+ // Pushi Channel Implementation
70
+ // ===========================================
71
+
72
+ var Channel = function(pushi, name) {
73
+ this.pushi = pushi;
74
+ this.name = name;
75
+ this.data = null;
76
+ this.subscribed = false;
77
+ this.events = {};
78
+ };
79
+
80
+ Channel.prototype.setsubscribe = function(data) {
81
+ var alias = (data && data.alias) || [];
82
+ for (var index = 0; index < alias.length; index++) {
83
+ var name = alias[index];
84
+
85
+ var channel = new Channel(this.pushi, name);
86
+ this.pushi.channels[name] = channel;
87
+
88
+ this.pushi.onsubscribe(name, {});
89
+ }
90
+
91
+ this.data = data;
92
+ this.subscribed = true;
93
+ this.trigger("subscribe", data);
94
+ };
95
+
96
+ Channel.prototype.setunsubscribe = function(data) {
97
+ var alias = (data && data.alias) || [];
98
+ for (var index = 0; index < alias.length; index++) {
99
+ var name = alias[index];
100
+ this.pushi.onunsubscribe(name, {});
101
+ }
102
+
103
+ this.subscribed = false;
104
+ this.trigger("unsubscribe", data);
105
+ };
106
+
107
+ Channel.prototype.setlatest = function(data) {
108
+ this.trigger("latest", data);
109
+ };
110
+
111
+ Channel.prototype.setmessage = function(event, data, mid, timestamp) {
112
+ this.trigger(event, data, mid, timestamp);
113
+ };
114
+
115
+ Channel.prototype.send = function(event, data, echo, persist) {
116
+ this.pushi.sendChannel(event, data, this.name, echo, persist);
117
+ };
118
+
119
+ Channel.prototype.unsubscribe = function(callback) {
120
+ this.pushi.unsubscribe(this.name, callback);
121
+ };
122
+
123
+ Channel.prototype.latest = function(skip, count, callback) {
124
+ this.pushi.latest(this.name, skip, count, callback);
125
+ };
126
+
127
+ Channel.prototype.trigger = Observable.prototype.trigger;
128
+ Channel.prototype.bind = Observable.prototype.bind;
129
+ Channel.prototype.unbind = Observable.prototype.unbind;
130
+
131
+ // ===========================================
132
+ // Pushi Base Implementation
133
+ // ===========================================
134
+
135
+ var Pushi = function(appKey, options) {
136
+ this.init(appKey, options);
137
+ };
138
+
139
+ Pushi.prototype.init = function(appKey, options, callback) {
140
+ // tries to retrieve any previously existing instance
141
+ // of pushi for the provided key and in case it exists
142
+ // clones it and returns it as the properly initialized
143
+ // pushi instance (provides re-usage of resources)
144
+ var previous = PUSHI_CONNECTIONS[appKey];
145
+ if (previous) {
146
+ return this.clone(previous);
147
+ }
148
+
149
+ // runs the configuration operation for the current instance
150
+ // so that the state based configuration variables are set
151
+ // according to the provided (configuration) values
152
+ this.config(appKey, options);
153
+
154
+ // starts the various state related variables for
155
+ // the newly initialized pushi instance
156
+ this.socket = null;
157
+ this.socketId = null;
158
+ this.state = "disconnected";
159
+ this.channels = {};
160
+ this.events = {};
161
+ this.auths = {};
162
+ this._base = null;
163
+ this._cloned = false;
164
+
165
+ // updates the proper auth endpoint for the current
166
+ // instance so that the proper call is made if required
167
+ this.authEndpoint = this.options.authEndpoint;
168
+
169
+ // triggers the starts of the connection loading by calling
170
+ // the open (connection) method in the instance
171
+ this.open(callback);
172
+ };
173
+
174
+ Pushi.prototype.config = function(appKey, options) {
175
+ // runs the definition of a series of constant values that
176
+ // will be used as defaults for some options
177
+ var TIMEOUT = 5000;
178
+ var BASE_URL = "wss://puxiapp.com/";
179
+
180
+ // retrieves the proper values for the options, defaulting
181
+ // to the pre-defined (constant) values if that's required
182
+ var timeout = options.timeout || TIMEOUT;
183
+ var baseUrl = options.baseUrl || BASE_URL;
184
+ var baseWebUrl = options.baseWebUrl || null;
185
+ var appId = options.appId || null;
186
+ var appSecret = options.appSecret || null;
187
+
188
+ // removes any previously registered configuration for the
189
+ // the current instance app key (for cases of re-configuration)
190
+ delete PUSHI_CONNECTIONS[this.appKey];
191
+
192
+ // updates the various configuration related variables
193
+ // that will condition the way the pushi instance behaves
194
+ this.timeout = timeout;
195
+ this.url = baseUrl + appKey;
196
+ this.baseUrl = baseUrl;
197
+ this.baseWebUrl = baseWebUrl;
198
+ this.appKey = appKey;
199
+ this.appId = appId;
200
+ this.appSecret = appSecret;
201
+ this.authenticated = false;
202
+ this.options = options || {};
203
+
204
+ // sets the current connection for the app key value
205
+ // so that it gets re-used if that's requested
206
+ PUSHI_CONNECTIONS[appKey] = this;
207
+ };
208
+
209
+ Pushi.prototype.reconfig = function(appKey, options, callback) {
210
+ this.config(appKey, options);
211
+ this.reopen(callback);
212
+ };
213
+
214
+ Pushi.prototype.clone = function(base) {
215
+ // copies the complete set of attributes from the base
216
+ // object to the new one (cloned) so that they may be
217
+ // re-used for any future operation/action
218
+ this.timeout = base.timeout;
219
+ this.url = base.url;
220
+ this.baseUrl = base.baseUrl;
221
+ this.baseWebUrl = base.baseWebUrl;
222
+ this.appKey = base.appKey;
223
+ this.options = base.options;
224
+ this.socket = base.socket;
225
+ this.socketId = base.socketId;
226
+ this.state = base.state;
227
+ this.channels = [];
228
+ this.events = [];
229
+ this.auths = base.auths;
230
+ this.authEndpoint = base.authEndpoint;
231
+ this._base = base;
232
+ this._cloned = true;
233
+
234
+ // adds the current reference to the list of subscriptions
235
+ // for the socket that is going to be used (as expected)
236
+ this.socket.subscriptions.push(this);
237
+
238
+ // in case the current state of the connection is
239
+ // connected must simulate the connection by calling
240
+ // the appropriate handler with the correct data
241
+ if (this.state === "connected") {
242
+ var data = {
243
+ socket_id: this.socketId
244
+ };
245
+ this.onoconnect(data);
246
+ }
247
+ };
248
+
249
+ Pushi.prototype.open = function(callback) {
250
+ // in case the current state is not disconnected returns immediately
251
+ // as this is considered to be the only valid state for the operation
252
+ if (this.state !== "disconnected") {
253
+ return;
254
+ }
255
+
256
+ // retrieves the current context as a local variable and then tries
257
+ // to gather the subscriptions from the current socket defaulting to
258
+ // a simple list with the current instance otherwise, this will make
259
+ // possible the re-usage of previously existing subscriptions for the
260
+ // instance for cloning situations (as defined in specifications)
261
+ var self = this;
262
+ var subscriptions = this.socket ? this.socket.subscriptions : [this];
263
+
264
+ // creates the new websocket reference with the currently defined
265
+ // url, then updates the reference to the underlying subscriptions
266
+ // and sets the proper callback value for the socket operation
267
+ var socket = new WebSocket(this.url);
268
+ socket.subscriptions = subscriptions;
269
+ socket._callback = callback;
270
+
271
+ // creates the function that will initialize the instance's socket to
272
+ // the one that has now been created and then calls it to all the
273
+ // subscriptions of the current socket (sets socket for subscription)
274
+ var _init = function() {
275
+ this.socket = socket;
276
+ };
277
+ this.callobj(_init, subscriptions);
278
+
279
+ this.socket.onopen = function() {
280
+ this._callback && this._callback();
281
+ this._callback = null;
282
+ };
283
+
284
+ this.socket.onmessage = function(event) {
285
+ var data = null;
286
+ var message = event.data;
287
+ var json = JSON.parse(message);
288
+
289
+ var isConnected = self.state === "disconnected" && json.event === "pusher:connection_established";
290
+
291
+ if (isConnected) {
292
+ data = JSON.parse(json.data);
293
+ self.callobj(Pushi.prototype.onoconnect, this.subscriptions, data);
294
+ } else if (self.state === "connected") {
295
+ data = json;
296
+ self.callobj(Pushi.prototype.onmessage, this.subscriptions, data);
297
+ }
298
+
299
+ this._callback && this._callback();
300
+ this._callback = null;
301
+ };
302
+
303
+ this.socket.onclose = function() {
304
+ self.callobj(Pushi.prototype.onodisconnect, this.subscriptions);
305
+ this._callback && this._callback();
306
+ this._callback = null;
307
+ };
308
+ };
309
+
310
+ Pushi.prototype.close = function(callback) {
311
+ // in case the current state is not connected returns immediately
312
+ // as this is considered to be the only valid state for the operation
313
+ if (this.state !== "connected") {
314
+ return;
315
+ }
316
+
317
+ // updates the next operation callback reference in the socket to the
318
+ // provided callback so that it gets notified on closing
319
+ this.socket._callback = callback;
320
+
321
+ // closes the currently assigned socket, triggering a series of events
322
+ // that will update the current pushi status as disconnected
323
+ this.socket.close();
324
+ };
325
+
326
+ Pushi.prototype.reopen = function(callback) {
327
+ var self = this;
328
+ this.close(function() {
329
+ self.open(callback);
330
+ });
331
+ };
332
+
333
+ Pushi.prototype.callobj = function(callable, objects) {
334
+ var index = 0;
335
+ var args = [];
336
+
337
+ for (index = 2; index < arguments.length; index++) {
338
+ args.push(arguments[index]);
339
+ }
340
+
341
+ for (index = 0; index < objects.length; index++) {
342
+ var _object = objects[index];
343
+ callable.apply(_object, args);
344
+ }
345
+ };
346
+
347
+ Pushi.prototype.retry = function() {
348
+ // sets the current object context in the self variable
349
+ // to be used by the clojures that are going to be created
350
+ var self = this;
351
+
352
+ // in case this is a cloned object the retry operation
353
+ // is not possible because this object does not owns
354
+ // the underlying websocket object
355
+ if (this._cloned) {
356
+ return;
357
+ }
358
+
359
+ // sets the timeout for the new initialization of the
360
+ // object this value should not be to low that congests
361
+ // the server side nor to large that takes to long for
362
+ // the reconnection to take effect (bad user experience)
363
+ setTimeout(function() {
364
+ self.open();
365
+ }, this.timeout);
366
+ };
367
+
368
+ Pushi.prototype.onoconnect = function(data) {
369
+ this.socketId = data.socket_id;
370
+ this.state = "connected";
371
+ this.trigger("connect");
372
+ };
373
+
374
+ Pushi.prototype.onodisconnect = function(_data) {
375
+ this.socketId = null;
376
+ this.channels = {};
377
+ this.state = "disconnected";
378
+ this.trigger("disconnect");
379
+ this.retry();
380
+ };
381
+
382
+ Pushi.prototype.onsubscribe = function(channel, data) {
383
+ if (!this.channels[channel]) {
384
+ return;
385
+ }
386
+ var _channel = this.channels[channel];
387
+ _channel.setsubscribe(data);
388
+ this.trigger("subscribe", channel, data);
389
+ };
390
+
391
+ Pushi.prototype.onunsubscribe = function(channel, data) {
392
+ if (!this.channels[channel]) {
393
+ return;
394
+ }
395
+ var _channel = this.channels[channel];
396
+ delete this.channels[channel];
397
+ _channel.setunsubscribe(data);
398
+ this.trigger("unsubscribe", channel, data);
399
+ };
400
+
401
+ Pushi.prototype.onlatest = function(channel, data) {
402
+ if (!this.channels[channel]) {
403
+ return;
404
+ }
405
+ var _channel = this.channels[channel];
406
+ _channel.setlatest(data);
407
+ this.trigger("latest", channel, data);
408
+ };
409
+
410
+ Pushi.prototype.onmemberadded = function(channel, member) {
411
+ this.trigger("member_added", channel, member);
412
+ };
413
+
414
+ Pushi.prototype.onmemberremoved = function(channel, member) {
415
+ this.trigger("member_removed", channel, member);
416
+ };
417
+
418
+ Pushi.prototype.onmessage = function(json) {
419
+ var data = null;
420
+ var member = null;
421
+ var channel = json.channel;
422
+ var _channel = this.channels[channel];
423
+ var isPeer = channel.startsWith("peer-");
424
+ if (channel && !_channel && !isPeer) {
425
+ return;
426
+ }
427
+
428
+ switch (json.event) {
429
+ case "pusher_internal:subscription_succeeded":
430
+ data = JSON.parse(json.data);
431
+ this.onsubscribe(channel, data);
432
+ break;
433
+
434
+ case "pusher_internal:unsubscription_succeeded":
435
+ data = JSON.parse(json.data);
436
+ this.onunsubscribe(channel, data);
437
+ break;
438
+
439
+ case "pusher_internal:latest":
440
+ data = JSON.parse(json.data);
441
+ this.onlatest(channel, data);
442
+ break;
443
+
444
+ case "pusher:member_added":
445
+ member = JSON.parse(json.member);
446
+ this.onmemberadded(channel, member);
447
+ break;
448
+
449
+ case "pusher:member_removed":
450
+ member = JSON.parse(json.member);
451
+ this.onmemberremoved(channel, member);
452
+ break;
453
+ }
454
+
455
+ this.trigger(json.event, json.data, json.channel, json.mid, json.timestamp);
456
+ _channel && _channel.setmessage(json.event, json.data, json.mid, json.timestamp);
457
+ };
458
+
459
+ Pushi.prototype.send = function(json) {
460
+ var data = JSON.stringify(json);
461
+ this.socket.send(data);
462
+ };
463
+
464
+ Pushi.prototype.sendEvent = function(event, data, echo, persist) {
465
+ echo = echo === undefined ? false : echo;
466
+ persist = persist === undefined ? true : persist;
467
+ var json = {
468
+ event: event,
469
+ data: data,
470
+ echo: echo,
471
+ persist: persist
472
+ };
473
+ this.send(json);
474
+ };
475
+
476
+ Pushi.prototype.sendChannel = function(event, data, channel, echo, persist) {
477
+ echo = echo === undefined ? false : echo;
478
+ persist = persist === undefined ? true : persist;
479
+ var json = {
480
+ event: event,
481
+ data: data,
482
+ channel: channel,
483
+ echo: echo,
484
+ persist: persist
485
+ };
486
+ this.send(json);
487
+ };
488
+
489
+ Pushi.prototype.invalidate = function(channel) {
490
+ // in case the channel (name) value is provided
491
+ // removes its reference from the map associating
492
+ // the channel name with the channel info
493
+ if (channel) {
494
+ delete this.channels[channel];
495
+ }
496
+ // otherwise removes all the channel information from
497
+ // the channels map invalidating all of the elements
498
+ else {
499
+ this.channels = {};
500
+ }
501
+ };
502
+
503
+ Pushi.prototype.subscribe = function(channel, force, callback) {
504
+ // sets the current context in the self variable to
505
+ // be used latter for the clojure functions
506
+ var self = this;
507
+
508
+ // tries to retrieve the channel information for the
509
+ // provided channel name in case it's found returns
510
+ // the channel object immediately (avoids double
511
+ // registration of the channel)
512
+ var _channel = this.channels[channel];
513
+ if (_channel && !force) {
514
+ return _channel;
515
+ }
516
+
517
+ // in case this is a cloned proxy object we must also
518
+ // check if the base object is already subscribed for
519
+ // the channel for such cases the callback should be
520
+ // called immediately as there's no remote call to be
521
+ // performed for such situations
522
+ if (this._cloned) {
523
+ _channel = this._base.channels[channel];
524
+ if (_channel && !force) {
525
+ setTimeout(function() {
526
+ self.onsubscribe(channel, _channel.data);
527
+ });
528
+ this.channels[channel] = _channel;
529
+ return _channel;
530
+ }
531
+ }
532
+
533
+ // verifies if the current channel to be subscribed
534
+ // is of type private and in case it is uses the proper
535
+ // private way of subscription otherwise uses the public
536
+ // way for subscription (no authentication process)
537
+ var isPrivate = channel.startsWith("private-") || channel.startsWith("presence-") || channel.startsWith(
538
+ "personal-");
539
+ if (isPrivate) {
540
+ this.subscribePrivate(channel);
541
+ } else {
542
+ this.subscribePublic(channel);
543
+ }
544
+
545
+ // retrieves the channel as the name value and then creates
546
+ // a channel object with the current context and the name and
547
+ // then sets the channel in the channels map structure
548
+ var name = channel;
549
+ channel = new Channel(this, name);
550
+ this.channels[name] = channel;
551
+
552
+ // in case the callback function is defined registers for the
553
+ // subscribe event on the channel object
554
+ callback && channel.bind("subscribe", callback, true);
555
+
556
+ // returns the channel structure as a result of this function
557
+ // to be used by the caller method or function
558
+ return channel;
559
+ };
560
+
561
+ Pushi.prototype.unsubscribe = function(channel, callback) {
562
+ // verifies if the channel is currently defined in the
563
+ // list of channels for the connection if not returns immediately
564
+ if (!this.channels[channel]) {
565
+ return;
566
+ }
567
+
568
+ // sends the event for the unsubscription of the channel through
569
+ // the current pushi socket so that no more messages are received
570
+ // regarding the provided channel
571
+ this.sendEvent("pusher:unsubscribe", {
572
+ channel: channel
573
+ });
574
+
575
+ // sets the channel as the name value and then tries to retrieve
576
+ // the channel structure for the provided name
577
+ var name = channel;
578
+ channel = this.channels[name];
579
+
580
+ // in case the callback function is defined registers for the
581
+ // unsubscribe event on the channel object
582
+ callback && channel.bind("unsubscribe", callback, true);
583
+
584
+ // returns the channel structure to the caller function so that
585
+ // may be used for any other operations pending
586
+ return channel;
587
+ };
588
+
589
+ Pushi.prototype.latest = function(channel, skip, count, callback) {
590
+ // sets the default values for the latest retrieval, so that if
591
+ // they are not provided values are ensured
592
+ skip = skip || 0;
593
+ count = count || 10;
594
+
595
+ // verifies if the channel is currently defined in the
596
+ // list of channels for the connection if not returns immediately
597
+ if (!this.channels[channel] && !channel.startsWith("peer-")) {
598
+ return;
599
+ }
600
+
601
+ // sends the event for the latest (retrieval) of the channel through
602
+ // the current pushi socket so that the latest messages are retrieved
603
+ this.sendEvent("pusher:latest", {
604
+ channel: channel,
605
+ skip: skip,
606
+ count: count
607
+ });
608
+
609
+ // sets the channel as the name value and then tries to retrieve
610
+ // the channel structure for the provided name, note that the ensure
611
+ // call will make sure that at least one channel object exists
612
+ var name = channel;
613
+ channel = this.ensureChannel(name);
614
+
615
+ // in case the callback function is defined registers for the
616
+ // latest event on the channel object
617
+ callback && channel.bind("latest", callback, true);
618
+
619
+ // returns the channel structure to the caller function so that
620
+ // may be used for any other operations pending
621
+ return channel;
622
+ };
623
+
624
+ Pushi.prototype.ensureChannel = function(name) {
625
+ if (this.channels[name]) {
626
+ return this.channels[name];
627
+ }
628
+ var channel = new Channel(this, name);
629
+ this.channels[name] = channel;
630
+ return channel;
631
+ };
632
+
633
+ Pushi.prototype.subscribePublic = function(channel) {
634
+ this.sendEvent("pusher:subscribe", {
635
+ channel: channel
636
+ });
637
+ };
638
+
639
+ Pushi.prototype.subscribePrivate = function(channel) {
640
+ // in case no authentication endpoint exists returns immediately
641
+ // because there's not enough information to proceed with the
642
+ // authentication process for the private channel
643
+ if (!this.authEndpoint) {
644
+ throw new Error("No auth endpoint defined");
645
+ }
646
+
647
+ // sets the current context in the self variable to be
648
+ // used by the clojures in the current function
649
+ var self = this;
650
+
651
+ // constructs the get query part of the url with both the socket
652
+ // id of the current connection and the channel value for it
653
+ // then constructs the complete url value for the connection
654
+ var query = "?socket_id=" + this.socketId + "&channel=" + channel;
655
+ var url = this.authEndpoint + query;
656
+
657
+ // creates the remote async request that it's going
658
+ // to be used to retrieve the authentication information
659
+ // this is going to use the provided auth endpoint together
660
+ // with some of the current context
661
+ var request = new XMLHttpRequest();
662
+ request.open("get", url, true);
663
+ request.onreadystatechange = function() {
664
+ // in case the current state is not ready returns
665
+ // immediately as it's not a (to) success change
666
+ if (request.readyState !== 4) {
667
+ return;
668
+ }
669
+
670
+ // retrieves the response data and parses it as a json
671
+ // message and returns immediately in case no auth
672
+ // information is provided as part of the response
673
+ var result = JSON.parse(request.responseText);
674
+ if (!result.auth) {
675
+ return;
676
+ }
677
+
678
+ // sends a pusher subscribe event containing all of the
679
+ // channel information together with the auth token and
680
+ // the channel data to be used (in case it exists)
681
+ self.sendEvent("pusher:subscribe", {
682
+ channel: channel,
683
+ auth: result.auth,
684
+ channel_data: result.channel_data
685
+ });
686
+ };
687
+ request.send();
688
+ };
689
+
690
+ Pushi.prototype.isValid = function(appKey, baseUrl) {
691
+ return appKey === this.appKey && baseUrl === this.baseUrl;
692
+ };
693
+
694
+ // ===========================================
695
+ // Observable Support
696
+ // ===========================================
697
+
698
+ Pushi.prototype.trigger = Observable.prototype.trigger;
699
+ Pushi.prototype.bind = Observable.prototype.bind;
700
+ Pushi.prototype.unbind = Observable.prototype.unbind;
701
+
702
+ // ===========================================
703
+ // Authentication Support
704
+ // ===========================================
705
+
706
+ /**
707
+ * Logs in to the Pushi server using app credentials.
708
+ * This establishes a session that is required for Web Push API
709
+ * operations. Must be called before using any Web Push methods.
710
+ *
711
+ * @param {Object} options Configuration options:
712
+ * - appId: The app identifier (required if not provided in constructor)
713
+ * - appSecret: The app secret (required if not provided in constructor)
714
+ * @returns {Promise} Promise that resolves when logged in.
715
+ */
716
+ Pushi.prototype.login = function(options) {
717
+ var self = this;
718
+ options = options || {};
719
+
720
+ // uses the provided values or falls back to the configured ones
721
+ var appId = options.appId || this.appId;
722
+ var appSecret = options.appSecret || this.appSecret;
723
+
724
+ if (!appId) {
725
+ return Promise.reject(new Error("App ID is required for authentication"));
726
+ }
727
+ if (!appSecret) {
728
+ return Promise.reject(new Error("App secret is required for authentication"));
729
+ }
730
+
731
+ // builds the login URL with authentication parameters
732
+ var url = this._buildApiUrl("/login");
733
+ url += "?app_id=" + encodeURIComponent(appId);
734
+ url += "&app_key=" + encodeURIComponent(this.appKey);
735
+ url += "&app_secret=" + encodeURIComponent(appSecret);
736
+
737
+ return new Promise(function(resolve, reject) {
738
+ var request = new XMLHttpRequest();
739
+ request.open("GET", url, true);
740
+ request.withCredentials = true;
741
+ request.onreadystatechange = function() {
742
+ if (request.readyState !== 4) {
743
+ return;
744
+ }
745
+
746
+ if (request.status === 200) {
747
+ self.authenticated = true;
748
+ self.trigger("login");
749
+ resolve();
750
+ } else {
751
+ var error = new Error("Login failed: " + request.status);
752
+ reject(error);
753
+ }
754
+ };
755
+ request.send();
756
+ });
757
+ };
758
+
759
+ /**
760
+ * Logs out from the Pushi server, ending the current session.
761
+ *
762
+ * @returns {Promise} Promise that resolves when logged out.
763
+ */
764
+ Pushi.prototype.logout = function() {
765
+ var self = this;
766
+ var url = this._buildApiUrl("/logout");
767
+
768
+ return new Promise(function(resolve, reject) {
769
+ var request = new XMLHttpRequest();
770
+ request.open("GET", url, true);
771
+ request.withCredentials = true;
772
+ request.onreadystatechange = function() {
773
+ if (request.readyState !== 4) {
774
+ return;
775
+ }
776
+
777
+ if (request.status === 200) {
778
+ self.authenticated = false;
779
+ self.trigger("logout");
780
+ resolve();
781
+ } else {
782
+ var error = new Error("Logout failed: " + request.status);
783
+ reject(error);
784
+ }
785
+ };
786
+ request.send();
787
+ });
788
+ };
789
+
790
+ // ===========================================
791
+ // Web Push API Support
792
+ // ===========================================
793
+
794
+ /**
795
+ * Fetches the VAPID public key from the server. This key is
796
+ * needed to subscribe to push notifications using the browser's
797
+ * Push API (applicationServerKey parameter).
798
+ *
799
+ * @param {Function} callback Optional callback with (error, vapidPublicKey).
800
+ * @returns {Promise} Promise that resolves with the VAPID public key.
801
+ */
802
+ Pushi.prototype.getVapidPublicKey = function(callback) {
803
+ var url = this._buildApiUrl("/vapid_key");
804
+
805
+ return new Promise(function(resolve, reject) {
806
+ var request = new XMLHttpRequest();
807
+ request.open("GET", url, true);
808
+ request.withCredentials = true;
809
+ request.onreadystatechange = function() {
810
+ if (request.readyState !== 4) {
811
+ return;
812
+ }
813
+
814
+ if (request.status === 200) {
815
+ var result = JSON.parse(request.responseText);
816
+ var publicKey = result.vapid_public_key;
817
+ callback && callback(null, publicKey);
818
+ resolve(publicKey);
819
+ } else {
820
+ var error = new Error("Failed to get VAPID public key: " + request.status);
821
+ callback && callback(error, null);
822
+ reject(error);
823
+ }
824
+ };
825
+ request.send();
826
+ });
827
+ };
828
+
829
+ /**
830
+ * Requests notification permission from the user. This must be
831
+ * called before attempting to subscribe to push notifications.
832
+ *
833
+ * @returns {Promise} Promise that resolves with permission state
834
+ * ('granted', 'denied', or 'default').
835
+ */
836
+ Pushi.prototype.requestNotificationPermission = function() {
837
+ return new Promise(function(resolve, reject) {
838
+ if (!("Notification" in window)) {
839
+ reject(new Error("Notifications not supported in this browser"));
840
+ return;
841
+ }
842
+
843
+ Notification.requestPermission().then(function(permission) {
844
+ resolve(permission);
845
+ }).catch(function(error) {
846
+ reject(error);
847
+ });
848
+ });
849
+ };
850
+
851
+ /**
852
+ * Registers a service worker for handling push notifications.
853
+ * The service worker is required to receive and display push
854
+ * notifications in the browser.
855
+ *
856
+ * @param {String} swPath Path to the service worker file (default: '/sw.js').
857
+ * @returns {Promise} Promise that resolves with the ServiceWorkerRegistration.
858
+ */
859
+ Pushi.prototype.registerServiceWorker = function(swPath) {
860
+ swPath = swPath || "/sw.js";
861
+
862
+ return new Promise(function(resolve, reject) {
863
+ if (!("serviceWorker" in navigator)) {
864
+ reject(new Error("Service workers not supported in this browser"));
865
+ return;
866
+ }
867
+
868
+ navigator.serviceWorker.register(swPath).then(function(registration) {
869
+ resolve(registration);
870
+ }).catch(function(error) {
871
+ reject(error);
872
+ });
873
+ });
874
+ };
875
+
876
+ /**
877
+ * Gets the current push subscription from the browser's
878
+ * push manager, if one exists.
879
+ *
880
+ * @param {ServiceWorkerRegistration} registration The service worker registration.
881
+ * @returns {Promise} Promise that resolves with PushSubscription or null.
882
+ */
883
+ Pushi.prototype.getPushSubscription = function(registration) {
884
+ return registration.pushManager.getSubscription();
885
+ };
886
+
887
+ /**
888
+ * Subscribes the browser to push notifications using the
889
+ * Web Push API. Requires a VAPID public key from the server.
890
+ *
891
+ * @param {ServiceWorkerRegistration} registration The service worker registration.
892
+ * @param {String} vapidPublicKey The VAPID public key in base64url format.
893
+ * @returns {Promise} Promise that resolves with PushSubscription.
894
+ */
895
+ Pushi.prototype.subscribeToPush = function(registration, vapidPublicKey) {
896
+ var self = this;
897
+
898
+ return new Promise(function(resolve, reject) {
899
+ // converts the base64url public key to Uint8Array
900
+ // as required by the Push API
901
+ var applicationServerKey = self._urlBase64ToUint8Array(vapidPublicKey);
902
+
903
+ registration.pushManager.subscribe({
904
+ userVisibleOnly: true,
905
+ applicationServerKey: applicationServerKey
906
+ }).then(function(subscription) {
907
+ resolve(subscription);
908
+ }).catch(function(error) {
909
+ reject(error);
910
+ });
911
+ });
912
+ };
913
+
914
+ /**
915
+ * Extracts the subscription information from a PushSubscription
916
+ * object into a plain object suitable for sending to the server.
917
+ *
918
+ * @param {PushSubscription} subscription The browser's push subscription.
919
+ * @returns {Object} Object containing endpoint, p256dh, and auth keys.
920
+ */
921
+ Pushi.prototype.extractSubscriptionInfo = function(subscription) {
922
+ var json = subscription.toJSON();
923
+ return {
924
+ endpoint: json.endpoint,
925
+ p256dh: json.keys.p256dh,
926
+ auth: json.keys.auth
927
+ };
928
+ };
929
+
930
+ /**
931
+ * Sends the push subscription to the Pushi server to register
932
+ * for notifications on a specific event/channel.
933
+ *
934
+ * @param {Object} subscriptionInfo Object with endpoint, p256dh, auth.
935
+ * @param {String} event The event/channel name to subscribe to.
936
+ * @param {Object} options Optional settings (auth, unsubscribe).
937
+ * @returns {Promise} Promise that resolves with server response.
938
+ */
939
+ Pushi.prototype.sendSubscriptionToServer = function(subscriptionInfo, event, options) {
940
+ options = options || {};
941
+
942
+ var url = this._buildApiUrl("/web_pushes");
943
+
944
+ // adds optional query parameters
945
+ var params = [];
946
+ if (options.auth) {
947
+ params.push("auth=" + encodeURIComponent(options.auth));
948
+ }
949
+ if (options.unsubscribe !== undefined) {
950
+ params.push("unsubscribe=" + options.unsubscribe);
951
+ }
952
+ if (params.length > 0) {
953
+ url += "?" + params.join("&");
954
+ }
955
+
956
+ var data = {
957
+ endpoint: subscriptionInfo.endpoint,
958
+ p256dh: subscriptionInfo.p256dh,
959
+ auth: subscriptionInfo.auth,
960
+ event: event
961
+ };
962
+
963
+ return new Promise(function(resolve, reject) {
964
+ var request = new XMLHttpRequest();
965
+ request.open("POST", url, true);
966
+ request.withCredentials = true;
967
+ request.setRequestHeader("Content-Type", "application/json");
968
+ request.onreadystatechange = function() {
969
+ if (request.readyState !== 4) {
970
+ return;
971
+ }
972
+
973
+ if (request.status === 200 || request.status === 201) {
974
+ var result = JSON.parse(request.responseText);
975
+ resolve(result);
976
+ } else {
977
+ var error = new Error("Failed to send subscription to server: " + request.status);
978
+ reject(error);
979
+ }
980
+ };
981
+ request.send(JSON.stringify(data));
982
+ });
983
+ };
984
+
985
+ /**
986
+ * Removes the push subscription from the Pushi server for a
987
+ * specific event/channel or all events if event is not provided.
988
+ *
989
+ * @param {String} endpoint The push endpoint URL.
990
+ * @param {String} event The event/channel (optional, removes all if not provided).
991
+ * @returns {Promise} Promise that resolves with server response.
992
+ */
993
+ Pushi.prototype.removeSubscriptionFromServer = function(endpoint, event) {
994
+ // builds the URL with event as a query parameter
995
+ var path = "/web_pushes/" + encodeURIComponent(endpoint);
996
+ var url = this._buildApiUrl(path);
997
+ if (event) {
998
+ url += "?event=" + encodeURIComponent(event);
999
+ }
1000
+
1001
+ return new Promise(function(resolve, reject) {
1002
+ var request = new XMLHttpRequest();
1003
+ request.open("DELETE", url, true);
1004
+ request.withCredentials = true;
1005
+ request.onreadystatechange = function() {
1006
+ if (request.readyState !== 4) {
1007
+ return;
1008
+ }
1009
+
1010
+ if (request.status === 200) {
1011
+ var result = JSON.parse(request.responseText);
1012
+ resolve(result);
1013
+ } else {
1014
+ var error = new Error("Failed to remove subscription from server: " + request.status);
1015
+ reject(error);
1016
+ }
1017
+ };
1018
+ request.send();
1019
+ });
1020
+ };
1021
+
1022
+ /**
1023
+ * High-level method to set up Web Push notifications. This handles
1024
+ * the complete flow: requesting permission, registering service worker,
1025
+ * subscribing to push, and registering with the server.
1026
+ *
1027
+ * IMPORTANT: You must call login() before using this method.
1028
+ *
1029
+ * @param {String} event The event/channel to subscribe to.
1030
+ * @param {Object} options Configuration options including:
1031
+ * - swPath: Service worker path (default: '/sw.js')
1032
+ * - auth: Authentication token for private channels
1033
+ * - unsubscribe: Remove existing subscriptions (default: true)
1034
+ * @returns {Promise} Promise that resolves with the subscription info.
1035
+ */
1036
+ Pushi.prototype.setupWebPush = function(event, options) {
1037
+ var self = this;
1038
+ options = options || {};
1039
+
1040
+ // checks if the user is logged in
1041
+ if (!this.authenticated) {
1042
+ return Promise.reject(new Error(
1043
+ "Login required. Call login() before setupWebPush()"
1044
+ ));
1045
+ }
1046
+
1047
+ var registration = null;
1048
+ var vapidPublicKey = null;
1049
+
1050
+ return this.requestNotificationPermission()
1051
+ .then(function(permission) {
1052
+ if (permission !== "granted") {
1053
+ throw new Error("Notification permission denied");
1054
+ }
1055
+ return self.registerServiceWorker(options.swPath);
1056
+ })
1057
+ .then(function(reg) {
1058
+ registration = reg;
1059
+ return self.getVapidPublicKey();
1060
+ })
1061
+ .then(function(key) {
1062
+ vapidPublicKey = key;
1063
+ return self.subscribeToPush(registration, vapidPublicKey);
1064
+ })
1065
+ .then(function(subscription) {
1066
+ var info = self.extractSubscriptionInfo(subscription);
1067
+ return self.sendSubscriptionToServer(info, event, options)
1068
+ .then(function(result) {
1069
+ return {
1070
+ subscription: subscription,
1071
+ subscriptionInfo: info,
1072
+ serverResponse: result
1073
+ };
1074
+ });
1075
+ });
1076
+ };
1077
+
1078
+ /**
1079
+ * High-level method to unsubscribe from Web Push notifications.
1080
+ * This removes the subscription from both the browser and the server.
1081
+ *
1082
+ * IMPORTANT: You must call login() before using this method.
1083
+ *
1084
+ * @param {String} event The event/channel to unsubscribe from (optional).
1085
+ * @returns {Promise} Promise that resolves when unsubscribed.
1086
+ */
1087
+ Pushi.prototype.teardownWebPush = function(event) {
1088
+ var self = this;
1089
+
1090
+ // checks if the user is logged in
1091
+ if (!this.authenticated) {
1092
+ return Promise.reject(new Error(
1093
+ "Login required. Call login() before teardownWebPush()"
1094
+ ));
1095
+ }
1096
+
1097
+ return navigator.serviceWorker.ready
1098
+ .then(function(registration) {
1099
+ return registration.pushManager.getSubscription();
1100
+ })
1101
+ .then(function(subscription) {
1102
+ if (!subscription) {
1103
+ return null;
1104
+ }
1105
+
1106
+ var endpoint = subscription.endpoint;
1107
+
1108
+ // unsubscribes from the browser first, then removes
1109
+ // the subscription from the server
1110
+ return subscription.unsubscribe()
1111
+ .then(function() {
1112
+ return self.removeSubscriptionFromServer(endpoint, event);
1113
+ });
1114
+ });
1115
+ };
1116
+
1117
+ // ===========================================
1118
+ // Helper Methods
1119
+ // ===========================================
1120
+
1121
+ /**
1122
+ * Builds an API URL for the Web Push methods. Uses session-based
1123
+ * authentication, so no app key is included in the URL.
1124
+ *
1125
+ * @param {String} path The API path (e.g., '/vapid_key').
1126
+ * @returns {String} The complete API URL.
1127
+ * @private
1128
+ */
1129
+ Pushi.prototype._buildApiUrl = function(path) {
1130
+ return this._getBaseWebUrl() + path;
1131
+ };
1132
+
1133
+ /**
1134
+ * Gets the base HTTP URL for API calls. Derives it from the
1135
+ * WebSocket URL if not explicitly configured.
1136
+ *
1137
+ * @returns {String} The base HTTP URL without trailing slash.
1138
+ * @private
1139
+ */
1140
+ Pushi.prototype._getBaseWebUrl = function() {
1141
+ var baseWebUrl = this.baseWebUrl;
1142
+ if (!baseWebUrl) {
1143
+ baseWebUrl = this.baseUrl.replace("wss://", "https://").replace("ws://", "http://");
1144
+ }
1145
+ return baseWebUrl.replace(/\/$/, "");
1146
+ };
1147
+
1148
+ /**
1149
+ * Converts a base64url string to Uint8Array. This is used
1150
+ * internally to convert the VAPID public key to the format
1151
+ * required by the Push API (applicationServerKey).
1152
+ *
1153
+ * @param {String} base64String Base64url encoded string.
1154
+ * @returns {Uint8Array} The decoded bytes.
1155
+ * @private
1156
+ */
1157
+ Pushi.prototype._urlBase64ToUint8Array = function(base64String) {
1158
+ // adds padding if needed (base64url doesn't require padding
1159
+ // but atob requires it)
1160
+ var padding = "=".repeat((4 - base64String.length % 4) % 4);
1161
+ var base64 = (base64String + padding)
1162
+ .replace(/-/g, "+")
1163
+ .replace(/_/g, "/");
1164
+
1165
+ var rawData = window.atob(base64);
1166
+ var outputArray = new Uint8Array(rawData.length);
1167
+
1168
+ for (var i = 0; i < rawData.length; ++i) {
1169
+ outputArray[i] = rawData.charCodeAt(i);
1170
+ }
1171
+ return outputArray;
1172
+ };
1173
+
1174
+ if (typeof String.prototype.startsWith !== "function") {
1175
+ String.prototype.startsWith = function(string) {
1176
+ return this.slice(0, string.length) === string;
1177
+ };
1178
+ }
1179
+
1180
+ // Module exports for Node.js/CommonJS environments
1181
+ if (typeof module !== "undefined" && module.exports) {
1182
+ module.exports = { Pushi: Pushi, Channel: Channel, Observable: Observable };
1183
+ }
package/pushi.mjs ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * ESM wrapper for pushi.js
3
+ * This allows importing pushi in ESM environments while keeping
4
+ * the main pushi.js file compatible with legacy browsers.
5
+ */
6
+
7
+ import { createRequire } from "module";
8
+ const require = createRequire(import.meta.url);
9
+ const pushi = require("./pushi.js");
10
+
11
+ export const { Pushi, Channel, Observable } = pushi;
12
+ export default pushi;