laravel-echo-api-gateway 0.5.8 → 0.5.9

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.
@@ -1,148 +1,177 @@
1
- import { Channel } from "./Channel";
2
-
3
- export type Options = { authEndpoint: string, host: string, bearerToken: string, auth: any, debug: boolean };
4
-
5
- export type MessageBody = { event: string, channel?: string, data: object };
1
+ import type { Channel } from "./Channel";
2
+
3
+ export type ChannelAuthorizationCallback = (error: unknown, data: AuthorizationData | null) => void;
4
+
5
+ export type ChannelAuthorization = {
6
+ endpoint?: string,
7
+ headers?: Record<string, string>,
8
+ customHandler?: (params: { socketId: string, channelName: string }, callback: ChannelAuthorizationCallback) => void,
9
+ };
10
+
11
+ export type Options = {
12
+ host?: string,
13
+ authEndpoint?: string,
14
+ auth?: { headers: Record<string, string> },
15
+ channelAuthorization?: ChannelAuthorization,
16
+ bearerToken?: string | null,
17
+ csrfToken?: string | null,
18
+ namespace?: string | false,
19
+ debug?: boolean,
20
+ /** Milliseconds between keep-alive pings. API Gateway closes idle connections after 10 minutes. */
21
+ pingInterval?: number,
22
+ /** Milliseconds to wait before the first reconnect attempt. Later attempts back off exponentially. */
23
+ reconnectDelay?: number,
24
+ /** Upper bound for the reconnect delay, in milliseconds. */
25
+ maxReconnectDelay?: number,
26
+ [key: string]: any,
27
+ };
28
+
29
+ export type MessageBody = { event: string, channel?: string, data?: any };
30
+
31
+ export type AuthorizationData = { auth: string, channel_data?: string };
32
+
33
+ export type AuthorizationError = { type: 'AuthError', status: number | null, error: unknown };
6
34
 
7
35
  const LOG_PREFIX = '[LE-AG-Websocket]';
8
36
 
9
- export class Websocket {
10
- buffer: Array<object> = [];
37
+ const OPEN = 1;
11
38
 
39
+ export class Websocket {
12
40
  options: Options;
13
41
 
14
- websocket: WebSocket;
42
+ websocket: WebSocket | undefined;
15
43
 
16
- private listeners: { [channelName: string]: { [eventName: string]: Function } } = {};
44
+ private buffer: Array<MessageBody> = [];
17
45
 
18
- private internalListeners: { [eventName: string]: Function } = {};
46
+ /**
47
+ * Every channel that should be subscribed, re-subscribed each time a new connection is identified.
48
+ */
49
+ private channels: Map<string, Channel> = new Map();
19
50
 
20
- private channelBacklog = [];
51
+ private listeners: { [channelName: string]: { [eventName: string]: Function[] } } = {};
21
52
 
22
- private socketId: string;
53
+ private socketId: string | undefined;
23
54
 
24
55
  private closing = false;
25
- private hasConnected = false;
26
56
 
27
- private pingInterval: NodeJS.Timeout;
57
+ private reconnectAttempts = 0;
58
+
59
+ private reconnectTimer: ReturnType<typeof setTimeout> | undefined;
60
+
61
+ private pingTimer: ReturnType<typeof setInterval> | undefined;
28
62
 
29
63
  constructor(options: Options) {
30
64
  this.options = options;
31
65
 
32
- this.connect(this.options.host);
33
-
34
- return this;
66
+ if (this.options.host) {
67
+ this.connect();
68
+ }
35
69
  }
36
70
 
37
- private connect(host: string): void {
71
+ private connect(): void {
72
+ const host = this.options.host;
38
73
 
39
- if (!host) {
40
- this.options.debug && console.error(LOG_PREFIX + `Cannont connect without host !`);
74
+ this.debug(`Trying to connect to ${host}...`);
41
75
 
42
- return;
43
- }
76
+ const websocket = new WebSocket(host);
77
+ this.websocket = websocket;
44
78
 
45
- this.options.debug && console.log(LOG_PREFIX + `Trying to connect to ${host}...` );
79
+ websocket.onopen = () => {
80
+ this.debug('Connected !');
81
+ this.reconnectAttempts = 0;
46
82
 
47
- this.websocket = new WebSocket(host);
83
+ this.send({ event: 'whoami' });
48
84
 
49
- this.websocket.onerror = () => {
85
+ while (this.buffer.length) {
86
+ this.send(this.buffer.shift());
87
+ }
50
88
 
51
- if (!this.hasConnected) {
89
+ this.startPing();
90
+ };
52
91
 
53
- setTimeout(() => {
54
- this.socketId = undefined;
55
- this.connect(host);
56
- }, 3000);
57
- }
92
+ websocket.onmessage = (messageEvent: MessageEvent) => {
93
+ this.handleMessage(messageEvent.data);
58
94
  };
59
95
 
60
- this.websocket.onopen = () => {
61
- this.options.debug && console.log(LOG_PREFIX + ' Connected !');
62
- this.hasConnected = true;
96
+ websocket.onerror = () => {
97
+ // A close event always follows an error, so reconnecting is handled in onclose.
98
+ this.debug('Connection error.');
99
+ };
63
100
 
64
- this.send({
65
- event: 'whoami',
66
- });
101
+ websocket.onclose = () => {
102
+ if (this.websocket !== websocket) {
103
+ return;
104
+ }
67
105
 
68
- while (this.buffer.length) {
69
- const message = this.buffer[0];
106
+ this.debug('Connection closed.');
70
107
 
71
- this.send(message);
108
+ this.stopPing();
109
+ this.socketId = undefined;
72
110
 
73
- this.buffer.splice(0, 1);
111
+ if (!this.closing) {
112
+ this.scheduleReconnect();
74
113
  }
114
+ };
115
+ }
75
116
 
76
- // Register events only once connected, or they won't be registered if connection failed/lost
77
-
78
- this.websocket.onmessage = (messageEvent: MessageEvent) => {
79
- const message = this.parseMessage(messageEvent.data);
80
- this.options.debug && console.log(LOG_PREFIX + ' onmessage', messageEvent.data);
81
-
82
- if (!message) {
83
- return;
84
- }
117
+ private scheduleReconnect(): void {
118
+ const baseDelay = this.options.reconnectDelay ?? 1000;
119
+ const maxDelay = this.options.maxReconnectDelay ?? 30000;
120
+ const delay = Math.min(baseDelay * 2 ** this.reconnectAttempts, maxDelay);
85
121
 
86
- if (message.channel) {
87
- this.options.debug && console.log(`${LOG_PREFIX} Received event ${message.event} on channel ${message.channel}`);
122
+ this.reconnectAttempts++;
123
+ this.debug(`Connection lost, reconnecting in ${delay}ms...`);
88
124
 
89
- if (this.listeners[message.channel] && this.listeners[message.channel][message.event]) {
90
- this.listeners[message.channel][message.event](message.data);
91
- }
125
+ clearTimeout(this.reconnectTimer);
126
+ this.reconnectTimer = setTimeout(() => this.connect(), delay);
127
+ }
92
128
 
93
- return;
94
- }
129
+ private startPing(): void {
130
+ this.stopPing();
95
131
 
96
- if (this.internalListeners[message.event]) {
97
- this.internalListeners[message.event](message.data);
98
- }
132
+ this.pingTimer = setInterval(() => {
133
+ if (this.socketIsReady()) {
134
+ this.debug('Sending ping');
135
+ this.send({ event: 'ping' });
99
136
  }
137
+ }, this.options.pingInterval ?? 60 * 1000);
138
+ }
100
139
 
140
+ private stopPing(): void {
141
+ clearInterval(this.pingTimer);
142
+ this.pingTimer = undefined;
143
+ }
101
144
 
102
- // send ping every 60 seconds to keep connection alive
103
- this.pingInterval = setInterval(() => {
104
- if (this.websocket.readyState === this.websocket.OPEN) {
105
- this.options.debug && console.log(LOG_PREFIX + ' Sending ping');
145
+ private handleMessage(body: string): void {
146
+ const message = this.parseMessage(body);
106
147
 
107
- this.send({
108
- event: 'ping',
109
- });
110
- }
111
- }, 60 * 1000);
148
+ if (!message) {
149
+ return;
112
150
  }
113
151
 
152
+ this.debug('onmessage', body);
114
153
 
115
- this.websocket.onclose = () => {
116
- this.options.debug && console.info('Connection closed.');
154
+ if (message.channel) {
155
+ this.dispatch(message.channel, message.event, message.data);
117
156
 
118
- if (this.closing){
119
- return;
120
- }
121
-
122
- this.hasConnected = false;
123
- this.options.debug && console.info('Connection lost, reconnecting...');
124
-
125
- setTimeout(() => {
126
- this.socketId = undefined;
127
- this.connect(host);
128
- }, 1000);
129
- };
157
+ return;
158
+ }
130
159
 
131
- this.on('whoami', ({ socket_id: socketId }) => {
132
- this.socketId = socketId;
160
+ if (message.event === 'whoami') {
161
+ this.socketId = message.data?.socket_id;
162
+ this.debug(`Just set socketId to ${this.socketId}`);
133
163
 
134
- this.options.debug && console.log(`${LOG_PREFIX} Just set socketId to ${socketId}`);
164
+ this.channels.forEach((channel) => this.actuallySubscribe(channel));
165
+ }
166
+ }
135
167
 
136
- // Handle the backlog and don't empty it, we'll need it if we lose connection
137
- let channel: Channel;
168
+ private dispatch(channelName: string, event: string, data: unknown): void {
169
+ const callbacks = this.listeners[channelName]?.[event] ?? [];
138
170
 
139
- for(channel of this.channelBacklog){
140
- this.actuallySubscribe(channel);
141
- }
142
- });
171
+ callbacks.slice().forEach((callback) => callback(data));
143
172
  }
144
173
 
145
- protected parseMessage(body: string): MessageBody {
174
+ protected parseMessage(body: string): MessageBody | undefined {
146
175
  try {
147
176
  return JSON.parse(body);
148
177
  } catch (error) {
@@ -152,17 +181,18 @@ export class Websocket {
152
181
  }
153
182
  }
154
183
 
155
- getSocketId(): string {
184
+ getSocketId(): string | undefined {
156
185
  return this.socketId;
157
186
  }
158
187
 
159
188
  private socketIsReady(): boolean {
160
- return this.websocket.readyState === this.websocket.OPEN;
189
+ return this.websocket?.readyState === OPEN;
161
190
  }
162
191
 
163
- send(message: object): void {
192
+ send(message: MessageBody): void {
164
193
  if (this.socketIsReady()) {
165
194
  this.websocket.send(JSON.stringify(message));
195
+
166
196
  return;
167
197
  }
168
198
 
@@ -171,110 +201,129 @@ export class Websocket {
171
201
 
172
202
  close(): void {
173
203
  this.closing = true;
174
- this.internalListeners = {};
175
204
 
176
- clearInterval(this.pingInterval);
177
- this.pingInterval = undefined;
205
+ clearTimeout(this.reconnectTimer);
206
+ this.stopPing();
178
207
 
179
- this.websocket.close();
208
+ this.websocket?.close();
180
209
  }
181
210
 
182
211
  subscribe(channel: Channel): void {
183
- if (this.getSocketId()) {
212
+ this.channels.set(channel.name, channel);
213
+
214
+ if (this.socketId && this.socketIsReady()) {
184
215
  this.actuallySubscribe(channel);
185
216
  } else {
186
- this.options.debug && console.log(`${LOG_PREFIX} subscribe - push channel backlog for channel ${channel.name}`);
187
-
188
- this.channelBacklog.push(channel);
217
+ this.debug(`Channel ${channel.name} will be subscribed once connected`);
189
218
  }
190
219
  }
191
220
 
192
221
  private actuallySubscribe(channel: Channel): void {
193
- if (channel.name.startsWith('private-') || channel.name.startsWith('presence-')) {
194
- this.options.debug && console.log(`${LOG_PREFIX} Sending auth request for channel ${channel.name}`);
222
+ if (!channel.name.startsWith('private-') && !channel.name.startsWith('presence-')) {
223
+ this.debug(`Subscribing to channel ${channel.name}`);
224
+ this.send({ event: 'subscribe', data: { channel: channel.name } });
225
+
226
+ return;
227
+ }
228
+
229
+ const socketId = this.socketId;
195
230
 
196
- if (this.options.bearerToken) {
197
- this.options.auth.headers['Authorization'] = 'Bearer ' + this.options.bearerToken;
231
+ this.debug(`Sending auth request for channel ${channel.name}`);
232
+
233
+ this.authorize(channel.name, socketId).then((data) => {
234
+ // Skip outdated authorizations: the connection changed or the channel was left meanwhile.
235
+ if (socketId !== this.socketId || this.channels.get(channel.name) !== channel) {
236
+ return;
198
237
  }
199
238
 
200
- fetch(this.options.authEndpoint, {
201
- method: 'POST',
202
- headers: {
203
- 'Content-Type': 'application/json',
204
- ...this.options.auth.headers,
205
- },
206
- body: JSON.stringify({
207
- socket_id: this.getSocketId(),
208
- channel_name: channel.name,
209
- }),
210
- }).then((response) => {
211
- if (!response.ok) {
212
- throw new Error(`Auth request failed: ${response.status}`);
213
- }
214
-
215
- return response.json();
216
- }).then((data) => {
217
- this.options.debug && console.log(`${LOG_PREFIX} Subscribing to private channel ${channel.name}`);
218
-
219
- this.send({
220
- event: 'subscribe',
221
- data: {
222
- channel: channel.name,
223
- ...data,
224
- },
225
- });
226
- }).catch((error) => {
227
- this.options.debug && console.log(`${LOG_PREFIX} Auth request for channel ${channel.name} failed`);
228
- this.options.debug && console.error(error);
229
- })
230
- } else {
231
- this.options.debug && console.log(`${LOG_PREFIX} Subscribing to channel ${channel.name}`);
239
+ this.debug(`Subscribing to private channel ${channel.name}`);
240
+ this.send({ event: 'subscribe', data: { channel: channel.name, ...data } });
241
+ }).catch((error: AuthorizationError) => {
242
+ this.debug(`Auth request for channel ${channel.name} failed`, error);
243
+ this.dispatch(channel.name, 'error', error);
244
+ });
245
+ }
246
+
247
+ private authorize(channelName: string, socketId: string): Promise<AuthorizationData> {
248
+ const channelAuthorization = this.options.channelAuthorization ?? {};
232
249
 
233
- this.send({
234
- event: 'subscribe',
235
- data: {
236
- channel: channel.name,
237
- },
250
+ if (typeof channelAuthorization.customHandler === 'function') {
251
+ return new Promise((resolve, reject) => {
252
+ channelAuthorization.customHandler({ socketId, channelName }, (error, data) => {
253
+ if (error) {
254
+ reject(this.authorizationError(error, (error as any)?.status ?? null));
255
+ } else {
256
+ resolve(data);
257
+ }
258
+ });
238
259
  });
239
260
  }
240
- }
241
261
 
242
- unsubscribe(channel: Channel): void {
243
- this.options.debug && console.log(`${LOG_PREFIX} unsubscribe for channel ${channel.name}`);
244
-
245
- this.send({
246
- event: 'unsubscribe',
247
- data: {
248
- channel: channel.name,
262
+ return fetch(channelAuthorization.endpoint ?? this.options.authEndpoint, {
263
+ method: 'POST',
264
+ headers: {
265
+ 'Content-Type': 'application/json',
266
+ 'Accept': 'application/json',
267
+ ...this.options.auth?.headers,
268
+ ...channelAuthorization.headers,
249
269
  },
270
+ body: JSON.stringify({
271
+ socket_id: socketId,
272
+ channel_name: channelName,
273
+ }),
274
+ }).then((response) => {
275
+ if (!response.ok) {
276
+ throw this.authorizationError(new Error(`Auth request failed: ${response.status}`), response.status);
277
+ }
278
+
279
+ return response.json();
280
+ }, (error) => {
281
+ throw this.authorizationError(error, null);
250
282
  });
283
+ }
251
284
 
252
- if (this.listeners[channel.name]) {
285
+ private authorizationError(error: unknown, status: number | null): AuthorizationError {
286
+ return { type: 'AuthError', status, error };
287
+ }
288
+
289
+ unsubscribe(channel: Channel): void {
290
+ this.debug(`unsubscribe for channel ${channel.name}`);
291
+
292
+ if (this.channels.get(channel.name) === channel) {
293
+ this.channels.delete(channel.name);
253
294
  delete this.listeners[channel.name];
254
295
  }
296
+
297
+ if (this.socketId && this.socketIsReady()) {
298
+ this.send({ event: 'unsubscribe', data: { channel: channel.name } });
299
+ }
255
300
  }
256
301
 
257
- on(event: string, callback: Function = null): void {
258
- this.options.debug && console.log(`${LOG_PREFIX} on event ${event} ...`);
302
+ bind(channel: Channel, event: string, callback: Function): void {
303
+ this.debug(`bind event ${event} for channel ${channel.name} ...`);
259
304
 
260
- this.internalListeners[event] = callback;
305
+ this.listeners[channel.name] ??= {};
306
+ this.listeners[channel.name][event] ??= [];
307
+ this.listeners[channel.name][event].push(callback);
261
308
  }
262
309
 
263
- bind(channel: Channel, event: string, callback: Function): void {
264
- this.options.debug && console.log(`${LOG_PREFIX} bind event ${event} for channel ${channel.name} ...`);
310
+ unbind(channel: Channel, event: string, callback?: Function): void {
311
+ this.debug(`unbind event ${event} for channel ${channel.name} ...`);
265
312
 
266
- if (!this.listeners[channel.name]) {
267
- this.listeners[channel.name] = {};
313
+ const callbacks = this.listeners[channel.name]?.[event];
314
+
315
+ if (!callbacks) {
316
+ return;
268
317
  }
269
318
 
270
- this.listeners[channel.name][event] = callback;
319
+ if (callback) {
320
+ this.listeners[channel.name][event] = callbacks.filter((existing) => existing !== callback);
321
+ } else {
322
+ delete this.listeners[channel.name][event];
323
+ }
271
324
  }
272
325
 
273
- unbindEvent(channel: Channel, event: string, callback: Function = null): void {
274
- this.options.debug && console.log(`${LOG_PREFIX} unbind event ${event} for channel ${channel.name} ...`);
275
-
276
- if (this.internalListeners[event] && (callback === null || this.internalListeners[event] === callback)) {
277
- delete this.internalListeners[event];
278
- }
326
+ private debug(...messages: unknown[]): void {
327
+ this.options.debug && console.log(LOG_PREFIX, ...messages);
279
328
  }
280
329
  }