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.
- package/.eslintrc.js +1 -0
- package/.github/workflows/ci.yml +49 -45
- package/README.md +93 -17
- package/dist/Channel.d.ts +89 -0
- package/dist/Connector.d.ts +92 -0
- package/dist/EventFormatter.d.ts +10 -0
- package/dist/Websocket.d.ts +78 -0
- package/dist/laravel-echo-api-gateway.common.js +403 -762
- package/dist/laravel-echo-api-gateway.iife.js +487 -846
- package/dist/laravel-echo-api-gateway.js +401 -760
- package/jest.config.js +3 -7
- package/js-src/Channel.ts +74 -62
- package/js-src/Connector.ts +100 -40
- package/js-src/EventFormatter.ts +24 -0
- package/js-src/Websocket.ts +217 -168
- package/js-tests/Connector.test.ts +295 -60
- package/js-tests/EchoCompatibility.test.ts +96 -0
- package/js-tests/helpers.ts +37 -0
- package/package.json +23 -30
- package/phpstan.neon.dist +3 -12
- package/phpunit.xml +2 -2
- package/rollup.config.mjs +14 -0
- package/tsconfig.json +6 -6
- package/dist/js-src/Channel.d.ts +0 -68
- package/dist/js-src/Connector.d.ts +0 -49
- package/dist/js-src/Websocket.d.ts +0 -38
- package/rollup.config.js +0 -31
package/js-src/Websocket.ts
CHANGED
|
@@ -1,148 +1,177 @@
|
|
|
1
|
-
import { Channel } from "./Channel";
|
|
2
|
-
|
|
3
|
-
export type
|
|
4
|
-
|
|
5
|
-
export type
|
|
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
|
-
|
|
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
|
|
44
|
+
private buffer: Array<MessageBody> = [];
|
|
17
45
|
|
|
18
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
66
|
+
if (this.options.host) {
|
|
67
|
+
this.connect();
|
|
68
|
+
}
|
|
35
69
|
}
|
|
36
70
|
|
|
37
|
-
private connect(
|
|
71
|
+
private connect(): void {
|
|
72
|
+
const host = this.options.host;
|
|
38
73
|
|
|
39
|
-
|
|
40
|
-
this.options.debug && console.error(LOG_PREFIX + `Cannont connect without host !`);
|
|
74
|
+
this.debug(`Trying to connect to ${host}...`);
|
|
41
75
|
|
|
42
|
-
|
|
43
|
-
|
|
76
|
+
const websocket = new WebSocket(host);
|
|
77
|
+
this.websocket = websocket;
|
|
44
78
|
|
|
45
|
-
|
|
79
|
+
websocket.onopen = () => {
|
|
80
|
+
this.debug('Connected !');
|
|
81
|
+
this.reconnectAttempts = 0;
|
|
46
82
|
|
|
47
|
-
|
|
83
|
+
this.send({ event: 'whoami' });
|
|
48
84
|
|
|
49
|
-
|
|
85
|
+
while (this.buffer.length) {
|
|
86
|
+
this.send(this.buffer.shift());
|
|
87
|
+
}
|
|
50
88
|
|
|
51
|
-
|
|
89
|
+
this.startPing();
|
|
90
|
+
};
|
|
52
91
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
this.connect(host);
|
|
56
|
-
}, 3000);
|
|
57
|
-
}
|
|
92
|
+
websocket.onmessage = (messageEvent: MessageEvent) => {
|
|
93
|
+
this.handleMessage(messageEvent.data);
|
|
58
94
|
};
|
|
59
95
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
this.
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
101
|
+
websocket.onclose = () => {
|
|
102
|
+
if (this.websocket !== websocket) {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
67
105
|
|
|
68
|
-
|
|
69
|
-
const message = this.buffer[0];
|
|
106
|
+
this.debug('Connection closed.');
|
|
70
107
|
|
|
71
|
-
|
|
108
|
+
this.stopPing();
|
|
109
|
+
this.socketId = undefined;
|
|
72
110
|
|
|
73
|
-
|
|
111
|
+
if (!this.closing) {
|
|
112
|
+
this.scheduleReconnect();
|
|
74
113
|
}
|
|
114
|
+
};
|
|
115
|
+
}
|
|
75
116
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
87
|
-
|
|
122
|
+
this.reconnectAttempts++;
|
|
123
|
+
this.debug(`Connection lost, reconnecting in ${delay}ms...`);
|
|
88
124
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
125
|
+
clearTimeout(this.reconnectTimer);
|
|
126
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
127
|
+
}
|
|
92
128
|
|
|
93
|
-
|
|
94
|
-
|
|
129
|
+
private startPing(): void {
|
|
130
|
+
this.stopPing();
|
|
95
131
|
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
});
|
|
110
|
-
}
|
|
111
|
-
}, 60 * 1000);
|
|
148
|
+
if (!message) {
|
|
149
|
+
return;
|
|
112
150
|
}
|
|
113
151
|
|
|
152
|
+
this.debug('onmessage', body);
|
|
114
153
|
|
|
115
|
-
|
|
116
|
-
this.
|
|
154
|
+
if (message.channel) {
|
|
155
|
+
this.dispatch(message.channel, message.event, message.data);
|
|
117
156
|
|
|
118
|
-
|
|
119
|
-
|
|
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
|
-
|
|
132
|
-
this.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.
|
|
164
|
+
this.channels.forEach((channel) => this.actuallySubscribe(channel));
|
|
165
|
+
}
|
|
166
|
+
}
|
|
135
167
|
|
|
136
|
-
|
|
137
|
-
|
|
168
|
+
private dispatch(channelName: string, event: string, data: unknown): void {
|
|
169
|
+
const callbacks = this.listeners[channelName]?.[event] ?? [];
|
|
138
170
|
|
|
139
|
-
|
|
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
|
|
189
|
+
return this.websocket?.readyState === OPEN;
|
|
161
190
|
}
|
|
162
191
|
|
|
163
|
-
send(message:
|
|
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
|
-
|
|
177
|
-
this.
|
|
205
|
+
clearTimeout(this.reconnectTimer);
|
|
206
|
+
this.stopPing();
|
|
178
207
|
|
|
179
|
-
this.websocket
|
|
208
|
+
this.websocket?.close();
|
|
180
209
|
}
|
|
181
210
|
|
|
182
211
|
subscribe(channel: Channel): void {
|
|
183
|
-
|
|
212
|
+
this.channels.set(channel.name, channel);
|
|
213
|
+
|
|
214
|
+
if (this.socketId && this.socketIsReady()) {
|
|
184
215
|
this.actuallySubscribe(channel);
|
|
185
216
|
} else {
|
|
186
|
-
this.
|
|
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-')
|
|
194
|
-
this.
|
|
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
|
-
|
|
197
|
-
|
|
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
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
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
|
-
|
|
234
|
-
|
|
235
|
-
data
|
|
236
|
-
|
|
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
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
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
|
-
|
|
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
|
-
|
|
258
|
-
this.
|
|
302
|
+
bind(channel: Channel, event: string, callback: Function): void {
|
|
303
|
+
this.debug(`bind event ${event} for channel ${channel.name} ...`);
|
|
259
304
|
|
|
260
|
-
this.
|
|
305
|
+
this.listeners[channel.name] ??= {};
|
|
306
|
+
this.listeners[channel.name][event] ??= [];
|
|
307
|
+
this.listeners[channel.name][event].push(callback);
|
|
261
308
|
}
|
|
262
309
|
|
|
263
|
-
|
|
264
|
-
this.
|
|
310
|
+
unbind(channel: Channel, event: string, callback?: Function): void {
|
|
311
|
+
this.debug(`unbind event ${event} for channel ${channel.name} ...`);
|
|
265
312
|
|
|
266
|
-
|
|
267
|
-
|
|
313
|
+
const callbacks = this.listeners[channel.name]?.[event];
|
|
314
|
+
|
|
315
|
+
if (!callbacks) {
|
|
316
|
+
return;
|
|
268
317
|
}
|
|
269
318
|
|
|
270
|
-
|
|
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
|
-
|
|
274
|
-
this.options.debug && console.log(
|
|
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
|
}
|