@belysh/socket-bridge-client 1.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/LICENSE +21 -0
- package/README.md +76 -0
- package/dist/index.d.ts +85 -0
- package/dist/index.js +338 -0
- package/package.json +50 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Socket Bridge contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# Socket Bridge client
|
|
2
|
+
|
|
3
|
+
Optional, framework-neutral helper around Socket.IO. React is not required.
|
|
4
|
+
The gateway also accepts a plain `socket.io-client`.
|
|
5
|
+
|
|
6
|
+
The client is distributed as a tarball attached to each GitHub release. See the
|
|
7
|
+
[Laravel package README](https://github.com/Belysh/laravel-socket-bridge#readme)
|
|
8
|
+
for the matching installation URL. Build from source with `npm ci && npm run build`.
|
|
9
|
+
|
|
10
|
+
```ts
|
|
11
|
+
import { createBridge } from '@belysh/socket-bridge-client';
|
|
12
|
+
|
|
13
|
+
const bridge = createBridge({
|
|
14
|
+
url: 'http://localhost:6001',
|
|
15
|
+
tokenEndpoint: '/socket-bridge/token',
|
|
16
|
+
onResync: () => refreshCurrentScreenFromApi(),
|
|
17
|
+
onError: (error) => console.error(error.message),
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
await bridge.join('private-chat.42'); // Exact Laravel PrivateChannel wire name.
|
|
21
|
+
const unsubscribe = bridge.on('chat.message.created', (payload, metadata) => {
|
|
22
|
+
renderMessage(payload.message);
|
|
23
|
+
});
|
|
24
|
+
bridge.connect();
|
|
25
|
+
|
|
26
|
+
// HTTP mutations must carry the originating socket ID for Laravel toOthers().
|
|
27
|
+
await fetch('/messages', {
|
|
28
|
+
method: 'POST',
|
|
29
|
+
headers: bridge.headers({ 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }),
|
|
30
|
+
body: JSON.stringify({ text: 'Hello' }),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
// A result resolves only after Laravel processes the command, not on enqueue.
|
|
34
|
+
// Submit after the socket is connected, for example from a UI event handler.
|
|
35
|
+
const commandId = crypto.randomUUID();
|
|
36
|
+
const message = await bridge.command('chat.message.create', { text: 'Hello' }, commandId);
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
For bearer authentication, provide `getToken`, or `tokenHeaders` returning an
|
|
40
|
+
Authorization header. Credentials go to Laravel's ticket endpoint, not the
|
|
41
|
+
gateway. A fresh one-use ticket is obtained on every reconnect.
|
|
42
|
+
|
|
43
|
+
Cookie applications must already establish their Laravel session and CSRF
|
|
44
|
+
cookie/token. The helper forwards a Blade `csrf-token` meta tag or an existing
|
|
45
|
+
`XSRF-TOKEN` cookie; it does not invent a login flow. Responses `{token: ...}`
|
|
46
|
+
and `{data: {token: ...}}` are supported.
|
|
47
|
+
|
|
48
|
+
Join before or after connecting. Reconnect reauthorizes remembered channels and
|
|
49
|
+
calls `onResync`; the gateway does not persist application history for clients.
|
|
50
|
+
`bridge.on` suppresses duplicate event IDs within a bounded per-listener window.
|
|
51
|
+
Revoked subscriptions invoke `onError` with code `subscription.revoked` and are
|
|
52
|
+
removed from automatic reconnection. Subscribe again explicitly if access changes.
|
|
53
|
+
Use `bridge.socket` for raw Socket.IO access. It remains the same connection.
|
|
54
|
+
|
|
55
|
+
After an unknown command outcome, retain and retry the same ID and payload
|
|
56
|
+
within the same authenticated session. Do not generate a fresh ID blindly:
|
|
57
|
+
the first attempt may already have committed. `BridgeError.commandId` preserves
|
|
58
|
+
the ID on timeout. `disconnect()` can reconnect; `destroy()` rejects pending
|
|
59
|
+
requests, removes subscriptions/listeners and permanently disposes the helper.
|
|
60
|
+
|
|
61
|
+
The helper refreshes an expiring socket session with a fresh Laravel ticket.
|
|
62
|
+
The gateway accepts the refresh only for the existing user, session and access
|
|
63
|
+
version. Temporary Redis/network failures retry with bounded backoff; a revoked
|
|
64
|
+
session stops automatic reconnection. Call `connect()` explicitly after the user
|
|
65
|
+
has authenticated again.
|
|
66
|
+
|
|
67
|
+
Channel authorization renews before its lease expires. During a temporary
|
|
68
|
+
Laravel outage, the gateway retains the last valid grant until its deadline,
|
|
69
|
+
then suspends delivery until reauthorization succeeds. Subscription intent is
|
|
70
|
+
retained and `onResync` runs after recovery. A definite denial removes the
|
|
71
|
+
subscription. Applications should refetch authoritative state in `onResync`.
|
|
72
|
+
|
|
73
|
+
Raw Socket.IO integrations can implement the same lifecycle by listening for
|
|
74
|
+
`bridge.session` and sending `session:refresh` with `{ token }` before expiry;
|
|
75
|
+
`bridge.disconnect.retryable` distinguishes a recoverable outage/expiry from
|
|
76
|
+
revocation. Only retry a server disconnect when this flag is `true`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { io, type Socket } from 'socket.io-client';
|
|
2
|
+
export type JsonObject = Record<string, unknown>;
|
|
3
|
+
export interface EventMetadata {
|
|
4
|
+
id: string;
|
|
5
|
+
created_at: string;
|
|
6
|
+
v: 1;
|
|
7
|
+
}
|
|
8
|
+
export interface BridgeErrorData {
|
|
9
|
+
code: string;
|
|
10
|
+
message: string;
|
|
11
|
+
details?: unknown;
|
|
12
|
+
}
|
|
13
|
+
export interface CommandResult<T = unknown> {
|
|
14
|
+
command_id: string;
|
|
15
|
+
result: {
|
|
16
|
+
ok: boolean;
|
|
17
|
+
data?: T;
|
|
18
|
+
error?: BridgeErrorData;
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
export interface BridgeOptions {
|
|
22
|
+
url: string;
|
|
23
|
+
tokenEndpoint?: string;
|
|
24
|
+
/** Override for bearer auth, custom routing, or a different response envelope. */
|
|
25
|
+
getToken?: () => Promise<string>;
|
|
26
|
+
fetch?: typeof globalThis.fetch;
|
|
27
|
+
tokenHeaders?: HeadersInit | (() => HeadersInit | Promise<HeadersInit>);
|
|
28
|
+
timeout?: number;
|
|
29
|
+
onResync?: () => void | Promise<void>;
|
|
30
|
+
onError?: (error: Error) => void;
|
|
31
|
+
/** Injectable for tests. Consumers normally use the default Socket.IO factory. */
|
|
32
|
+
socketFactory?: typeof io;
|
|
33
|
+
}
|
|
34
|
+
export declare class BridgeError extends Error {
|
|
35
|
+
readonly code: string;
|
|
36
|
+
readonly commandId?: string | undefined;
|
|
37
|
+
constructor(code: string, message: string, commandId?: string | undefined);
|
|
38
|
+
}
|
|
39
|
+
/** A thin helper; the underlying ordinary Socket.IO socket remains available. */
|
|
40
|
+
export declare class SocketBridge {
|
|
41
|
+
private readonly options;
|
|
42
|
+
readonly socket: Socket;
|
|
43
|
+
private readonly desiredChannels;
|
|
44
|
+
private readonly pending;
|
|
45
|
+
private readonly fetcher;
|
|
46
|
+
private readonly timeout;
|
|
47
|
+
private destroyed;
|
|
48
|
+
private wantConnected;
|
|
49
|
+
private terminal;
|
|
50
|
+
private retryableDisconnect;
|
|
51
|
+
private authError?;
|
|
52
|
+
private reconnectTimer?;
|
|
53
|
+
private refreshTimer?;
|
|
54
|
+
private reconnectAttempts;
|
|
55
|
+
private refreshAttempts;
|
|
56
|
+
private refreshGeneration;
|
|
57
|
+
private readonly channelRetries;
|
|
58
|
+
constructor(options: BridgeOptions);
|
|
59
|
+
connect(): this;
|
|
60
|
+
disconnect(): void;
|
|
61
|
+
destroy(): void;
|
|
62
|
+
join(channel: string): Promise<void>;
|
|
63
|
+
leave(channel: string): Promise<void>;
|
|
64
|
+
/** Dedupe is bounded and per listener, so multiple consumers all receive a new event. */
|
|
65
|
+
on<T = JsonObject>(event: string, listener: (payload: T, metadata?: EventMetadata) => void): () => void;
|
|
66
|
+
/** Keep id when retrying after an unknown outcome. Accepted ACK is not business success. */
|
|
67
|
+
command<T = unknown>(command: string, payload: JsonObject, id?: string): Promise<T>;
|
|
68
|
+
/** Merge into HTTP mutations to enable Laravel InteractsWithSockets / toOthers(). */
|
|
69
|
+
headers(initial?: HeadersInit): Headers;
|
|
70
|
+
private obtainToken;
|
|
71
|
+
private restoreSubscriptions;
|
|
72
|
+
private resync;
|
|
73
|
+
private isTerminal;
|
|
74
|
+
private stopForRevocation;
|
|
75
|
+
private backoff;
|
|
76
|
+
private scheduleReconnect;
|
|
77
|
+
private clearRefresh;
|
|
78
|
+
private scheduleRefresh;
|
|
79
|
+
private refresh;
|
|
80
|
+
private forgetChannel;
|
|
81
|
+
private failedSubscription;
|
|
82
|
+
private ack;
|
|
83
|
+
private report;
|
|
84
|
+
}
|
|
85
|
+
export declare function createBridge(options: BridgeOptions): SocketBridge;
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
import { io } from 'socket.io-client';
|
|
2
|
+
export class BridgeError extends Error {
|
|
3
|
+
code;
|
|
4
|
+
commandId;
|
|
5
|
+
constructor(code, message, commandId) {
|
|
6
|
+
super(message);
|
|
7
|
+
this.code = code;
|
|
8
|
+
this.commandId = commandId;
|
|
9
|
+
this.name = 'BridgeError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
/** A thin helper; the underlying ordinary Socket.IO socket remains available. */
|
|
13
|
+
export class SocketBridge {
|
|
14
|
+
options;
|
|
15
|
+
socket;
|
|
16
|
+
desiredChannels = new Set();
|
|
17
|
+
pending = new Map();
|
|
18
|
+
fetcher;
|
|
19
|
+
timeout;
|
|
20
|
+
destroyed = false;
|
|
21
|
+
wantConnected = false;
|
|
22
|
+
terminal = false;
|
|
23
|
+
retryableDisconnect = false;
|
|
24
|
+
authError;
|
|
25
|
+
reconnectTimer;
|
|
26
|
+
refreshTimer;
|
|
27
|
+
reconnectAttempts = 0;
|
|
28
|
+
refreshAttempts = 0;
|
|
29
|
+
refreshGeneration = 0;
|
|
30
|
+
channelRetries = new Map();
|
|
31
|
+
constructor(options) {
|
|
32
|
+
this.options = options;
|
|
33
|
+
this.fetcher = options.fetch ?? globalThis.fetch.bind(globalThis);
|
|
34
|
+
this.timeout = options.timeout ?? 15_000;
|
|
35
|
+
this.socket = (options.socketFactory ?? io)(options.url, {
|
|
36
|
+
autoConnect: false,
|
|
37
|
+
transports: ['websocket'],
|
|
38
|
+
withCredentials: true,
|
|
39
|
+
auth: (done) => {
|
|
40
|
+
this.authError = undefined;
|
|
41
|
+
this.obtainToken().then((token) => done({ token })).catch((error) => {
|
|
42
|
+
this.authError = error;
|
|
43
|
+
// End the handshake instead of hanging indefinitely; never reuse a consumed ticket.
|
|
44
|
+
done({ token: '' });
|
|
45
|
+
});
|
|
46
|
+
},
|
|
47
|
+
});
|
|
48
|
+
this.socket.on('connect', () => {
|
|
49
|
+
this.reconnectAttempts = 0;
|
|
50
|
+
this.retryableDisconnect = false;
|
|
51
|
+
if (this.reconnectTimer)
|
|
52
|
+
clearTimeout(this.reconnectTimer);
|
|
53
|
+
this.reconnectTimer = undefined;
|
|
54
|
+
void this.restoreSubscriptions();
|
|
55
|
+
});
|
|
56
|
+
this.socket.on('connect_error', (error) => {
|
|
57
|
+
const reason = this.authError ?? new BridgeError(error.data?.code ?? 'transport.unavailable', error.message);
|
|
58
|
+
this.authError = undefined;
|
|
59
|
+
this.report(reason);
|
|
60
|
+
if (this.isTerminal(reason))
|
|
61
|
+
this.stopForRevocation();
|
|
62
|
+
else
|
|
63
|
+
this.scheduleReconnect();
|
|
64
|
+
});
|
|
65
|
+
this.socket.on('bridge.disconnect', (packet) => {
|
|
66
|
+
this.retryableDisconnect = packet.retryable === true;
|
|
67
|
+
if (!this.retryableDisconnect)
|
|
68
|
+
this.stopForRevocation();
|
|
69
|
+
this.report(new BridgeError(packet.code, packet.retryable ? 'Realtime connection will be restored.' : 'Realtime access was revoked.'));
|
|
70
|
+
});
|
|
71
|
+
this.socket.on('disconnect', () => {
|
|
72
|
+
this.clearRefresh();
|
|
73
|
+
for (const retry of this.channelRetries.values())
|
|
74
|
+
clearTimeout(retry.timer);
|
|
75
|
+
this.channelRetries.clear();
|
|
76
|
+
if (this.retryableDisconnect)
|
|
77
|
+
this.scheduleReconnect();
|
|
78
|
+
});
|
|
79
|
+
this.socket.on('bridge.session', (packet) => {
|
|
80
|
+
if (Number.isFinite(packet.refresh_after_ms) && packet.refresh_after_ms >= 0) {
|
|
81
|
+
this.refreshAttempts = 0;
|
|
82
|
+
this.scheduleRefresh(Math.min(packet.refresh_after_ms, 2_147_483_647));
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
this.socket.on('bridge.subscription.restored', () => { void this.resync(); });
|
|
86
|
+
this.socket.on('bridge.subscription.revoked', (packet) => {
|
|
87
|
+
this.forgetChannel(packet.channel);
|
|
88
|
+
this.report(new BridgeError('subscription.revoked', `Access to ${packet.channel} was revoked.`));
|
|
89
|
+
});
|
|
90
|
+
this.socket.on('bridge.command.result', (packet) => {
|
|
91
|
+
const item = this.pending.get(packet.command_id);
|
|
92
|
+
if (!item)
|
|
93
|
+
return;
|
|
94
|
+
clearTimeout(item.timer);
|
|
95
|
+
this.pending.delete(packet.command_id);
|
|
96
|
+
if (packet.result.ok)
|
|
97
|
+
item.resolve(packet.result.data);
|
|
98
|
+
else
|
|
99
|
+
item.reject(new BridgeError(packet.result.error?.code ?? 'command.failed', packet.result.error?.message ?? 'Command failed', packet.command_id));
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
connect() {
|
|
103
|
+
if (this.destroyed)
|
|
104
|
+
throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
|
|
105
|
+
this.wantConnected = true;
|
|
106
|
+
this.terminal = false;
|
|
107
|
+
this.socket.connect();
|
|
108
|
+
return this;
|
|
109
|
+
}
|
|
110
|
+
disconnect() {
|
|
111
|
+
this.wantConnected = false;
|
|
112
|
+
if (this.reconnectTimer)
|
|
113
|
+
clearTimeout(this.reconnectTimer);
|
|
114
|
+
this.reconnectTimer = undefined;
|
|
115
|
+
this.clearRefresh();
|
|
116
|
+
for (const retry of this.channelRetries.values())
|
|
117
|
+
clearTimeout(retry.timer);
|
|
118
|
+
this.channelRetries.clear();
|
|
119
|
+
this.socket.disconnect();
|
|
120
|
+
}
|
|
121
|
+
destroy() {
|
|
122
|
+
this.destroyed = true;
|
|
123
|
+
this.disconnect();
|
|
124
|
+
this.socket.removeAllListeners();
|
|
125
|
+
for (const [id, item] of this.pending) {
|
|
126
|
+
clearTimeout(item.timer);
|
|
127
|
+
item.reject(new BridgeError('client.destroyed', 'Client was destroyed; the command may still be processing.', id));
|
|
128
|
+
}
|
|
129
|
+
this.pending.clear();
|
|
130
|
+
this.desiredChannels.clear();
|
|
131
|
+
}
|
|
132
|
+
async join(channel) {
|
|
133
|
+
this.desiredChannels.add(channel);
|
|
134
|
+
if (this.socket.connected) {
|
|
135
|
+
try {
|
|
136
|
+
await this.ack('room:join', { channel });
|
|
137
|
+
}
|
|
138
|
+
catch (error) {
|
|
139
|
+
this.failedSubscription(channel, error);
|
|
140
|
+
throw error;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
async leave(channel) {
|
|
145
|
+
this.forgetChannel(channel);
|
|
146
|
+
if (this.socket.connected)
|
|
147
|
+
await this.ack('room:leave', { channel });
|
|
148
|
+
}
|
|
149
|
+
/** Dedupe is bounded and per listener, so multiple consumers all receive a new event. */
|
|
150
|
+
on(event, listener) {
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
const wrapped = (payload, metadata) => {
|
|
153
|
+
if (metadata?.id) {
|
|
154
|
+
if (seen.has(metadata.id))
|
|
155
|
+
return;
|
|
156
|
+
seen.add(metadata.id);
|
|
157
|
+
if (seen.size > 1_000)
|
|
158
|
+
seen.delete(seen.values().next().value);
|
|
159
|
+
}
|
|
160
|
+
listener(payload, metadata);
|
|
161
|
+
};
|
|
162
|
+
this.socket.on(event, wrapped);
|
|
163
|
+
return () => this.socket.off(event, wrapped);
|
|
164
|
+
}
|
|
165
|
+
/** Keep id when retrying after an unknown outcome. Accepted ACK is not business success. */
|
|
166
|
+
command(command, payload, id = globalThis.crypto.randomUUID()) {
|
|
167
|
+
if (!this.socket.connected)
|
|
168
|
+
return Promise.reject(new BridgeError('socket.disconnected', 'Connect before submitting commands.', id));
|
|
169
|
+
if (this.pending.has(id))
|
|
170
|
+
return Promise.reject(new BridgeError('command.pending', 'This command already has a pending request.', id));
|
|
171
|
+
return new Promise((resolve, reject) => {
|
|
172
|
+
const timer = setTimeout(() => {
|
|
173
|
+
this.pending.delete(id);
|
|
174
|
+
reject(new BridgeError('command.timeout', 'Command outcome is unknown. Retry with the same command ID.', id));
|
|
175
|
+
}, this.timeout);
|
|
176
|
+
this.pending.set(id, { resolve: resolve, reject, timer });
|
|
177
|
+
void this.ack('command', { id, command, payload }).catch((error) => {
|
|
178
|
+
const item = this.pending.get(id);
|
|
179
|
+
if (!item)
|
|
180
|
+
return;
|
|
181
|
+
clearTimeout(item.timer);
|
|
182
|
+
this.pending.delete(id);
|
|
183
|
+
reject(error instanceof BridgeError ? new BridgeError(error.code, error.message, id) : error);
|
|
184
|
+
});
|
|
185
|
+
});
|
|
186
|
+
}
|
|
187
|
+
/** Merge into HTTP mutations to enable Laravel InteractsWithSockets / toOthers(). */
|
|
188
|
+
headers(initial) {
|
|
189
|
+
const headers = new Headers(initial);
|
|
190
|
+
if (this.socket.connected && this.socket.id)
|
|
191
|
+
headers.set('X-Socket-ID', this.socket.id);
|
|
192
|
+
return headers;
|
|
193
|
+
}
|
|
194
|
+
async obtainToken() {
|
|
195
|
+
if (this.options.getToken)
|
|
196
|
+
return this.options.getToken();
|
|
197
|
+
const initial = typeof this.options.tokenHeaders === 'function' ? await this.options.tokenHeaders() : this.options.tokenHeaders;
|
|
198
|
+
const headers = new Headers(initial);
|
|
199
|
+
headers.set('Accept', 'application/json');
|
|
200
|
+
if (typeof document !== 'undefined') {
|
|
201
|
+
const csrf = document.querySelector('meta[name="csrf-token"]')?.content;
|
|
202
|
+
const xsrf = document.cookie.split('; ').find((v) => v.startsWith('XSRF-TOKEN='))?.slice(11);
|
|
203
|
+
if (csrf && !headers.has('X-CSRF-TOKEN'))
|
|
204
|
+
headers.set('X-CSRF-TOKEN', csrf);
|
|
205
|
+
else if (xsrf && !headers.has('X-XSRF-TOKEN'))
|
|
206
|
+
headers.set('X-XSRF-TOKEN', decodeURIComponent(xsrf));
|
|
207
|
+
}
|
|
208
|
+
const response = await this.fetcher(this.options.tokenEndpoint ?? '/socket-bridge/token', {
|
|
209
|
+
method: 'POST', credentials: 'include', headers, signal: AbortSignal.timeout(this.timeout),
|
|
210
|
+
});
|
|
211
|
+
if (!response.ok)
|
|
212
|
+
throw new BridgeError([401, 403, 419].includes(response.status) ? 'auth.revoked' : 'auth.ticket_failed', `Laravel rejected the connection ticket request (${response.status}).`);
|
|
213
|
+
const body = await response.json();
|
|
214
|
+
const token = body.token ?? body.data?.token;
|
|
215
|
+
if (!token)
|
|
216
|
+
throw new BridgeError('auth.invalid_response', 'Ticket response did not include a token.');
|
|
217
|
+
return token;
|
|
218
|
+
}
|
|
219
|
+
async restoreSubscriptions() {
|
|
220
|
+
for (const channel of this.desiredChannels) {
|
|
221
|
+
try {
|
|
222
|
+
await this.ack('room:join', { channel });
|
|
223
|
+
}
|
|
224
|
+
catch (error) {
|
|
225
|
+
this.failedSubscription(channel, error);
|
|
226
|
+
this.report(error);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
await this.resync();
|
|
230
|
+
}
|
|
231
|
+
async resync() {
|
|
232
|
+
try {
|
|
233
|
+
await this.options.onResync?.();
|
|
234
|
+
}
|
|
235
|
+
catch (error) {
|
|
236
|
+
this.report(error);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
isTerminal(error) {
|
|
240
|
+
return error instanceof BridgeError && ['unauthenticated', 'session_revoked', 'auth.revoked', 'forbidden', 'invalid_channel', 'subscription_cancelled'].includes(error.code);
|
|
241
|
+
}
|
|
242
|
+
stopForRevocation() {
|
|
243
|
+
this.terminal = true;
|
|
244
|
+
if (this.reconnectTimer)
|
|
245
|
+
clearTimeout(this.reconnectTimer);
|
|
246
|
+
this.reconnectTimer = undefined;
|
|
247
|
+
this.clearRefresh();
|
|
248
|
+
for (const retry of this.channelRetries.values())
|
|
249
|
+
clearTimeout(retry.timer);
|
|
250
|
+
this.channelRetries.clear();
|
|
251
|
+
}
|
|
252
|
+
backoff(attempt) { return Math.min(10_000, 250 * 2 ** Math.min(attempt, 6)) * (0.75 + Math.random() * 0.5); }
|
|
253
|
+
scheduleReconnect() {
|
|
254
|
+
if (this.destroyed || this.terminal || !this.wantConnected || this.reconnectTimer)
|
|
255
|
+
return;
|
|
256
|
+
this.reconnectTimer = setTimeout(() => {
|
|
257
|
+
this.reconnectTimer = undefined;
|
|
258
|
+
if (!this.destroyed && !this.terminal && this.wantConnected && !this.socket.connected)
|
|
259
|
+
this.socket.connect();
|
|
260
|
+
}, this.backoff(this.reconnectAttempts++));
|
|
261
|
+
}
|
|
262
|
+
clearRefresh() {
|
|
263
|
+
this.refreshGeneration++;
|
|
264
|
+
if (this.refreshTimer)
|
|
265
|
+
clearTimeout(this.refreshTimer);
|
|
266
|
+
this.refreshTimer = undefined;
|
|
267
|
+
}
|
|
268
|
+
scheduleRefresh(ms) {
|
|
269
|
+
this.clearRefresh();
|
|
270
|
+
const generation = this.refreshGeneration;
|
|
271
|
+
this.refreshTimer = setTimeout(() => {
|
|
272
|
+
this.refreshTimer = undefined;
|
|
273
|
+
void this.refresh(generation);
|
|
274
|
+
}, ms);
|
|
275
|
+
}
|
|
276
|
+
async refresh(generation) {
|
|
277
|
+
if (!this.socket.connected || this.terminal || this.destroyed)
|
|
278
|
+
return;
|
|
279
|
+
try {
|
|
280
|
+
const token = await this.obtainToken();
|
|
281
|
+
if (!this.socket.connected || generation !== this.refreshGeneration)
|
|
282
|
+
return;
|
|
283
|
+
await this.ack('session:refresh', { token });
|
|
284
|
+
// The gateway sends the next expiry/schedule using bridge.session.
|
|
285
|
+
}
|
|
286
|
+
catch (error) {
|
|
287
|
+
if (generation !== this.refreshGeneration)
|
|
288
|
+
return;
|
|
289
|
+
this.report(error);
|
|
290
|
+
if (this.isTerminal(error)) {
|
|
291
|
+
this.stopForRevocation();
|
|
292
|
+
this.socket.disconnect();
|
|
293
|
+
}
|
|
294
|
+
else if (this.socket.connected)
|
|
295
|
+
this.scheduleRefresh(this.backoff(this.refreshAttempts++));
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
forgetChannel(channel) {
|
|
299
|
+
this.desiredChannels.delete(channel);
|
|
300
|
+
const retry = this.channelRetries.get(channel);
|
|
301
|
+
if (retry)
|
|
302
|
+
clearTimeout(retry.timer);
|
|
303
|
+
this.channelRetries.delete(channel);
|
|
304
|
+
}
|
|
305
|
+
failedSubscription(channel, error, attempts = 0) {
|
|
306
|
+
if (this.isTerminal(error)) {
|
|
307
|
+
this.forgetChannel(channel);
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
if (!this.desiredChannels.has(channel) || this.channelRetries.has(channel) || !this.socket.connected || this.destroyed || this.terminal)
|
|
311
|
+
return;
|
|
312
|
+
const timer = setTimeout(() => {
|
|
313
|
+
this.channelRetries.delete(channel);
|
|
314
|
+
if (!this.desiredChannels.has(channel) || !this.socket.connected || this.destroyed || this.terminal)
|
|
315
|
+
return;
|
|
316
|
+
void this.ack('room:join', { channel }).then(() => this.resync(), next => {
|
|
317
|
+
this.failedSubscription(channel, next, attempts + 1);
|
|
318
|
+
this.report(next);
|
|
319
|
+
});
|
|
320
|
+
}, this.backoff(attempts));
|
|
321
|
+
this.channelRetries.set(channel, { attempts, timer });
|
|
322
|
+
}
|
|
323
|
+
ack(event, payload) {
|
|
324
|
+
return new Promise((resolve, reject) => {
|
|
325
|
+
this.socket.timeout(this.timeout).emit(event, payload, (error, response) => {
|
|
326
|
+
if (error)
|
|
327
|
+
return reject(new BridgeError('socket.ack_timeout', 'Gateway acknowledgement timed out. The outcome may be unknown.'));
|
|
328
|
+
if (!response?.ok)
|
|
329
|
+
return reject(new BridgeError(response?.error?.code ?? 'socket.rejected', response?.error?.message ?? 'Gateway rejected the request.'));
|
|
330
|
+
resolve(response);
|
|
331
|
+
});
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
report(error) {
|
|
335
|
+
this.options.onError?.(error instanceof Error ? error : new Error(String(error)));
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
export function createBridge(options) { return new SocketBridge(options); }
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@belysh/socket-bridge-client",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Framework-neutral Socket.IO client for Laravel Socket Bridge",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": {
|
|
11
|
+
"types": "./dist/index.d.ts",
|
|
12
|
+
"import": "./dist/index.js"
|
|
13
|
+
}
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist",
|
|
17
|
+
"README.md",
|
|
18
|
+
"LICENSE"
|
|
19
|
+
],
|
|
20
|
+
"scripts": {
|
|
21
|
+
"build": "tsc -p tsconfig.json",
|
|
22
|
+
"test": "npm run build && node --test test/*.test.mjs",
|
|
23
|
+
"prepack": "npm run build"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"socket.io-client": "^4.8.1"
|
|
27
|
+
},
|
|
28
|
+
"devDependencies": {
|
|
29
|
+
"typescript": "^5.9.3"
|
|
30
|
+
},
|
|
31
|
+
"engines": {
|
|
32
|
+
"node": ">=20"
|
|
33
|
+
},
|
|
34
|
+
"repository": {
|
|
35
|
+
"type": "git",
|
|
36
|
+
"url": "git+https://github.com/Belysh/laravel-socket-bridge.git",
|
|
37
|
+
"directory": "client"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://github.com/Belysh/laravel-socket-bridge#readme",
|
|
40
|
+
"bugs": {
|
|
41
|
+
"url": "https://github.com/Belysh/laravel-socket-bridge/issues"
|
|
42
|
+
},
|
|
43
|
+
"keywords": [
|
|
44
|
+
"laravel",
|
|
45
|
+
"websocket",
|
|
46
|
+
"socket.io",
|
|
47
|
+
"redis",
|
|
48
|
+
"realtime"
|
|
49
|
+
]
|
|
50
|
+
}
|