@belysh/socket-bridge-client 1.0.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +48 -15
- package/dist/channel.d.ts +39 -0
- package/dist/channel.js +116 -0
- package/dist/index.d.ts +24 -2
- package/dist/index.js +171 -27
- package/dist/react.d.ts +10 -0
- package/dist/react.js +34 -0
- package/dist/vue.d.ts +20 -0
- package/dist/vue.js +41 -0
- package/package.json +29 -3
package/README.md
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
# Socket Bridge client
|
|
2
2
|
|
|
3
|
-
Optional, framework-neutral helper around Socket.IO. React
|
|
3
|
+
Optional, framework-neutral helper around Socket.IO. React and Vue are not required.
|
|
4
4
|
The gateway also accepts a plain `socket.io-client`.
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
```bash
|
|
7
|
+
npm install @belysh/socket-bridge-client
|
|
8
|
+
```
|
|
9
|
+
|
|
10
|
+
Use the matching release of the
|
|
11
|
+
[Laravel package](https://github.com/Belysh/laravel-socket-bridge#readme).
|
|
12
|
+
Compiled ESM and TypeScript declarations are included. The tested client tarball
|
|
13
|
+
is also attached to each GitHub release. To develop the client itself, build from
|
|
14
|
+
source with `npm ci && npm run build`.
|
|
9
15
|
|
|
10
16
|
```ts
|
|
11
17
|
import { createBridge } from '@belysh/socket-bridge-client';
|
|
@@ -17,23 +23,28 @@ const bridge = createBridge({
|
|
|
17
23
|
onError: (error) => console.error(error.message),
|
|
18
24
|
});
|
|
19
25
|
|
|
20
|
-
|
|
21
|
-
const unsubscribe = bridge.on('chat.message.created', (payload, metadata) => {
|
|
26
|
+
const chat = bridge.private('chat.42').listen('chat.message.created', (payload, metadata) => {
|
|
22
27
|
renderMessage(payload.message);
|
|
23
28
|
});
|
|
24
29
|
bridge.connect();
|
|
25
30
|
|
|
26
|
-
//
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
})
|
|
31
|
+
// Call from a UI action after connection. Include the socket ID for toOthers().
|
|
32
|
+
async function sendMessage(text: string) {
|
|
33
|
+
if (!bridge.socket.connected) throw new Error('Realtime connection is not ready.');
|
|
34
|
+
return fetch('/messages', {
|
|
35
|
+
method: 'POST',
|
|
36
|
+
headers: bridge.headers({ 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrf }),
|
|
37
|
+
body: JSON.stringify({ text }),
|
|
38
|
+
});
|
|
39
|
+
}
|
|
32
40
|
|
|
33
41
|
// A result resolves only after Laravel processes the command, not on enqueue.
|
|
34
|
-
//
|
|
35
|
-
|
|
36
|
-
|
|
42
|
+
// Keep commandId in your UI state if the outcome is unknown and needs a retry.
|
|
43
|
+
async function sendCommand(text: string, commandId: string) {
|
|
44
|
+
return bridge.command('chat.message.create', { text }, commandId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// When this screen is removed: chat.dispose().
|
|
37
48
|
```
|
|
38
49
|
|
|
39
50
|
For bearer authentication, provide `getToken`, or `tokenHeaders` returning an
|
|
@@ -74,3 +85,25 @@ Raw Socket.IO integrations can implement the same lifecycle by listening for
|
|
|
74
85
|
`bridge.session` and sending `session:refresh` with `{ token }` before expiry;
|
|
75
86
|
`bridge.disconnect.retryable` distinguishes a recoverable outage/expiry from
|
|
76
87
|
revocation. Only retry a server disconnect when this flag is `true`.
|
|
88
|
+
|
|
89
|
+
## Channels, React and Vue (1.1)
|
|
90
|
+
|
|
91
|
+
```ts
|
|
92
|
+
const orders = bridge.private('orders.42')
|
|
93
|
+
.listen('order.updated', order => updateOrder(order))
|
|
94
|
+
.error(error => showError(error.message));
|
|
95
|
+
// Remove only this screen's listeners/subscription reference:
|
|
96
|
+
orders.dispose();
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Channel-scoped listeners require the matching 1.1+ gateway channel metadata;
|
|
100
|
+
legacy global `on()` and manual `join()` remain available. Multiple handles share
|
|
101
|
+
a room and only the last disposal leaves it. `presence()` exposes authoritative
|
|
102
|
+
`here` snapshots and `joining`/`leaving` changes. `BridgeError.details` preserves
|
|
103
|
+
Laravel field validation errors.
|
|
104
|
+
|
|
105
|
+
Optional integrations are exported from `@belysh/socket-bridge-client/react`
|
|
106
|
+
(React 18/19) and `/vue` (Vue 3.5+). They provide `useSocketBridge`,
|
|
107
|
+
`useChannelEvent` and `usePresence`, with reference-counted subscriptions and
|
|
108
|
+
lifecycle cleanup. Neither framework is loaded by the base entry point.
|
|
109
|
+
See [the complete client guide](https://github.com/Belysh/laravel-socket-bridge/blob/main/docs/CLIENT.md).
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { EventMetadata, JsonObject, SocketBridge } from './index.js';
|
|
2
|
+
export interface PresenceMember<T = JsonObject> {
|
|
3
|
+
id: string;
|
|
4
|
+
info: T;
|
|
5
|
+
}
|
|
6
|
+
export type ChannelListener<T = JsonObject> = (payload: T, metadata?: EventMetadata) => void;
|
|
7
|
+
/** One subscription owner. Dispose only releases this owner's listeners and room reference. */
|
|
8
|
+
export declare class BridgeChannel {
|
|
9
|
+
private readonly bridge;
|
|
10
|
+
readonly name: string;
|
|
11
|
+
ready: Promise<void>;
|
|
12
|
+
private disposed;
|
|
13
|
+
private listeners;
|
|
14
|
+
private hereListeners;
|
|
15
|
+
private joiningListeners;
|
|
16
|
+
private leavingListeners;
|
|
17
|
+
private errorListeners;
|
|
18
|
+
private members?;
|
|
19
|
+
private lastError?;
|
|
20
|
+
constructor(bridge: SocketBridge, name: string);
|
|
21
|
+
listen<T = JsonObject>(event: string, listener: ChannelListener<T>): this;
|
|
22
|
+
stopListening<T = JsonObject>(event: string, listener?: ChannelListener<T>): this;
|
|
23
|
+
notification<T = JsonObject>(listener: ChannelListener<T>): this;
|
|
24
|
+
/** Called with each authoritative presence snapshot, including refreshed member information. */
|
|
25
|
+
here(listener: (members: PresenceMember[]) => void): this;
|
|
26
|
+
joining(listener: (member: PresenceMember) => void): this;
|
|
27
|
+
leaving(listener: (member: PresenceMember) => void): this;
|
|
28
|
+
error(listener: (error: Error) => void): this;
|
|
29
|
+
/** Release this handle. Other components subscribed to the same channel remain connected. */
|
|
30
|
+
dispose(): void;
|
|
31
|
+
leave(): void;
|
|
32
|
+
/** @internal */
|
|
33
|
+
receivePresence(members: PresenceMember[]): void;
|
|
34
|
+
/** @internal */
|
|
35
|
+
resetPresence(): void;
|
|
36
|
+
/** @internal */
|
|
37
|
+
receiveError(error: unknown): void;
|
|
38
|
+
private assertActive;
|
|
39
|
+
}
|
package/dist/channel.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
const notificationEvent = 'Illuminate\\Notifications\\Events\\BroadcastNotificationCreated';
|
|
2
|
+
/** One subscription owner. Dispose only releases this owner's listeners and room reference. */
|
|
3
|
+
export class BridgeChannel {
|
|
4
|
+
bridge;
|
|
5
|
+
name;
|
|
6
|
+
ready = Promise.resolve();
|
|
7
|
+
disposed = false;
|
|
8
|
+
listeners = new Map();
|
|
9
|
+
hereListeners = new Set();
|
|
10
|
+
joiningListeners = new Set();
|
|
11
|
+
leavingListeners = new Set();
|
|
12
|
+
errorListeners = new Set();
|
|
13
|
+
members;
|
|
14
|
+
lastError;
|
|
15
|
+
constructor(bridge, name) {
|
|
16
|
+
this.bridge = bridge;
|
|
17
|
+
this.name = name;
|
|
18
|
+
}
|
|
19
|
+
listen(event, listener) {
|
|
20
|
+
this.assertActive();
|
|
21
|
+
const listeners = this.listeners.get(event) ?? new Map();
|
|
22
|
+
if (!listeners.has(listener)) {
|
|
23
|
+
listeners.set(listener, this.bridge.on(event, (payload, metadata) => {
|
|
24
|
+
// Old gateways cannot safely support scoped listeners. Never fall back to unfiltered delivery.
|
|
25
|
+
if (metadata?.channels?.includes(this.name))
|
|
26
|
+
listener(payload, metadata);
|
|
27
|
+
}));
|
|
28
|
+
this.listeners.set(event, listeners);
|
|
29
|
+
}
|
|
30
|
+
return this;
|
|
31
|
+
}
|
|
32
|
+
stopListening(event, listener) {
|
|
33
|
+
const listeners = this.listeners.get(event);
|
|
34
|
+
if (listener) {
|
|
35
|
+
listeners?.get(listener)?.();
|
|
36
|
+
listeners?.delete(listener);
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
for (const stop of listeners?.values() ?? [])
|
|
40
|
+
stop();
|
|
41
|
+
this.listeners.delete(event);
|
|
42
|
+
}
|
|
43
|
+
return this;
|
|
44
|
+
}
|
|
45
|
+
notification(listener) { return this.listen(notificationEvent, listener); }
|
|
46
|
+
/** Called with each authoritative presence snapshot, including refreshed member information. */
|
|
47
|
+
here(listener) {
|
|
48
|
+
this.assertActive();
|
|
49
|
+
this.hereListeners.add(listener);
|
|
50
|
+
if (this.members)
|
|
51
|
+
listener(this.members);
|
|
52
|
+
return this;
|
|
53
|
+
}
|
|
54
|
+
joining(listener) { this.assertActive(); this.joiningListeners.add(listener); return this; }
|
|
55
|
+
leaving(listener) { this.assertActive(); this.leavingListeners.add(listener); return this; }
|
|
56
|
+
error(listener) {
|
|
57
|
+
this.assertActive();
|
|
58
|
+
this.errorListeners.add(listener);
|
|
59
|
+
if (this.lastError)
|
|
60
|
+
listener(this.lastError);
|
|
61
|
+
return this;
|
|
62
|
+
}
|
|
63
|
+
/** Release this handle. Other components subscribed to the same channel remain connected. */
|
|
64
|
+
dispose() {
|
|
65
|
+
if (this.disposed)
|
|
66
|
+
return;
|
|
67
|
+
this.disposed = true;
|
|
68
|
+
for (const event of this.listeners.keys())
|
|
69
|
+
this.stopListening(event);
|
|
70
|
+
this.hereListeners.clear();
|
|
71
|
+
this.joiningListeners.clear();
|
|
72
|
+
this.leavingListeners.clear();
|
|
73
|
+
this.errorListeners.clear();
|
|
74
|
+
this.members = undefined;
|
|
75
|
+
this.bridge.releaseChannel(this);
|
|
76
|
+
}
|
|
77
|
+
leave() { this.dispose(); }
|
|
78
|
+
/** @internal */
|
|
79
|
+
receivePresence(members) {
|
|
80
|
+
if (this.disposed)
|
|
81
|
+
return;
|
|
82
|
+
const previous = this.members;
|
|
83
|
+
this.members = members;
|
|
84
|
+
for (const listener of this.hereListeners)
|
|
85
|
+
listener(members);
|
|
86
|
+
// A first snapshot establishes a baseline; reconnects must not synthesize everyone joining.
|
|
87
|
+
if (!previous)
|
|
88
|
+
return;
|
|
89
|
+
const old = new Map(previous.map(member => [member.id, member]));
|
|
90
|
+
const current = new Map(members.map(member => [member.id, member]));
|
|
91
|
+
for (const [id, member] of current)
|
|
92
|
+
if (!old.has(id))
|
|
93
|
+
for (const listener of this.joiningListeners)
|
|
94
|
+
listener(member);
|
|
95
|
+
for (const [id, member] of old)
|
|
96
|
+
if (!current.has(id))
|
|
97
|
+
for (const listener of this.leavingListeners)
|
|
98
|
+
listener(member);
|
|
99
|
+
}
|
|
100
|
+
/** @internal */
|
|
101
|
+
resetPresence() {
|
|
102
|
+
this.members = undefined;
|
|
103
|
+
for (const listener of this.hereListeners)
|
|
104
|
+
listener([]);
|
|
105
|
+
}
|
|
106
|
+
/** @internal */
|
|
107
|
+
receiveError(error) {
|
|
108
|
+
if (this.disposed)
|
|
109
|
+
return;
|
|
110
|
+
this.lastError = error instanceof Error ? error : new Error(String(error));
|
|
111
|
+
for (const listener of this.errorListeners)
|
|
112
|
+
listener(this.lastError);
|
|
113
|
+
}
|
|
114
|
+
assertActive() { if (this.disposed)
|
|
115
|
+
throw new Error('This channel handle has been disposed. Create a new handle.'); }
|
|
116
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,9 +1,12 @@
|
|
|
1
1
|
import { io, type Socket } from 'socket.io-client';
|
|
2
|
+
import { BridgeChannel } from './channel.js';
|
|
3
|
+
export { BridgeChannel, type PresenceMember } from './channel.js';
|
|
2
4
|
export type JsonObject = Record<string, unknown>;
|
|
3
5
|
export interface EventMetadata {
|
|
4
6
|
id: string;
|
|
5
7
|
created_at: string;
|
|
6
|
-
v: 1;
|
|
8
|
+
v: 1; /** Canonical authorized channel targets; available from gateway 1.1. */
|
|
9
|
+
channels?: string[];
|
|
7
10
|
}
|
|
8
11
|
export interface BridgeErrorData {
|
|
9
12
|
code: string;
|
|
@@ -34,13 +37,22 @@ export interface BridgeOptions {
|
|
|
34
37
|
export declare class BridgeError extends Error {
|
|
35
38
|
readonly code: string;
|
|
36
39
|
readonly commandId?: string | undefined;
|
|
37
|
-
|
|
40
|
+
readonly details?: unknown | undefined;
|
|
41
|
+
constructor(code: string, message: string, commandId?: string | undefined, details?: unknown | undefined);
|
|
38
42
|
}
|
|
39
43
|
/** A thin helper; the underlying ordinary Socket.IO socket remains available. */
|
|
40
44
|
export declare class SocketBridge {
|
|
41
45
|
private readonly options;
|
|
42
46
|
readonly socket: Socket;
|
|
43
47
|
private readonly desiredChannels;
|
|
48
|
+
private readonly manualChannels;
|
|
49
|
+
private readonly channels;
|
|
50
|
+
private readonly channelJoins;
|
|
51
|
+
private readonly activeChannels;
|
|
52
|
+
private readonly presenceSnapshots;
|
|
53
|
+
private connectionGeneration;
|
|
54
|
+
private connectionReferences;
|
|
55
|
+
private managedConnection;
|
|
44
56
|
private readonly pending;
|
|
45
57
|
private readonly fetcher;
|
|
46
58
|
private readonly timeout;
|
|
@@ -57,10 +69,19 @@ export declare class SocketBridge {
|
|
|
57
69
|
private readonly channelRetries;
|
|
58
70
|
constructor(options: BridgeOptions);
|
|
59
71
|
connect(): this;
|
|
72
|
+
private beginConnection;
|
|
73
|
+
/** Reference-count a framework consumer without closing a manually connected socket. */
|
|
74
|
+
retainConnection(): () => void;
|
|
60
75
|
disconnect(): void;
|
|
61
76
|
destroy(): void;
|
|
62
77
|
join(channel: string): Promise<void>;
|
|
63
78
|
leave(channel: string): Promise<void>;
|
|
79
|
+
/** Create an independently disposable subscription to an exact canonical channel. */
|
|
80
|
+
channel(name: string): BridgeChannel;
|
|
81
|
+
private(name: string): BridgeChannel;
|
|
82
|
+
presence(name: string): BridgeChannel;
|
|
83
|
+
/** Internal lifetime hook used by channel handles. */
|
|
84
|
+
releaseChannel(handle: BridgeChannel): void;
|
|
64
85
|
/** Dedupe is bounded and per listener, so multiple consumers all receive a new event. */
|
|
65
86
|
on<T = JsonObject>(event: string, listener: (payload: T, metadata?: EventMetadata) => void): () => void;
|
|
66
87
|
/** Keep id when retrying after an unknown outcome. Accepted ACK is not business success. */
|
|
@@ -68,6 +89,7 @@ export declare class SocketBridge {
|
|
|
68
89
|
/** Merge into HTTP mutations to enable Laravel InteractsWithSockets / toOthers(). */
|
|
69
90
|
headers(initial?: HeadersInit): Headers;
|
|
70
91
|
private obtainToken;
|
|
92
|
+
private subscribeChannel;
|
|
71
93
|
private restoreSubscriptions;
|
|
72
94
|
private resync;
|
|
73
95
|
private isTerminal;
|
package/dist/index.js
CHANGED
|
@@ -1,11 +1,15 @@
|
|
|
1
1
|
import { io } from 'socket.io-client';
|
|
2
|
+
import { BridgeChannel } from './channel.js';
|
|
3
|
+
export { BridgeChannel } from './channel.js';
|
|
2
4
|
export class BridgeError extends Error {
|
|
3
5
|
code;
|
|
4
6
|
commandId;
|
|
5
|
-
|
|
7
|
+
details;
|
|
8
|
+
constructor(code, message, commandId, details) {
|
|
6
9
|
super(message);
|
|
7
10
|
this.code = code;
|
|
8
11
|
this.commandId = commandId;
|
|
12
|
+
this.details = details;
|
|
9
13
|
this.name = 'BridgeError';
|
|
10
14
|
}
|
|
11
15
|
}
|
|
@@ -14,6 +18,14 @@ export class SocketBridge {
|
|
|
14
18
|
options;
|
|
15
19
|
socket;
|
|
16
20
|
desiredChannels = new Set();
|
|
21
|
+
manualChannels = new Set();
|
|
22
|
+
channels = new Map();
|
|
23
|
+
channelJoins = new Map();
|
|
24
|
+
activeChannels = new Set();
|
|
25
|
+
presenceSnapshots = new Map();
|
|
26
|
+
connectionGeneration = 0;
|
|
27
|
+
connectionReferences = 0;
|
|
28
|
+
managedConnection = false;
|
|
17
29
|
pending = new Map();
|
|
18
30
|
fetcher;
|
|
19
31
|
timeout;
|
|
@@ -46,6 +58,9 @@ export class SocketBridge {
|
|
|
46
58
|
},
|
|
47
59
|
});
|
|
48
60
|
this.socket.on('connect', () => {
|
|
61
|
+
this.connectionGeneration++;
|
|
62
|
+
this.activeChannels.clear();
|
|
63
|
+
this.channelJoins.clear();
|
|
49
64
|
this.reconnectAttempts = 0;
|
|
50
65
|
this.retryableDisconnect = false;
|
|
51
66
|
if (this.reconnectTimer)
|
|
@@ -54,7 +69,7 @@ export class SocketBridge {
|
|
|
54
69
|
void this.restoreSubscriptions();
|
|
55
70
|
});
|
|
56
71
|
this.socket.on('connect_error', (error) => {
|
|
57
|
-
const reason = this.authError ?? new BridgeError(error.data?.code ?? 'transport.unavailable', error.message);
|
|
72
|
+
const reason = this.authError ?? new BridgeError(error.data?.code ?? 'transport.unavailable', error.data?.message ?? error.message, undefined, error.data?.details);
|
|
58
73
|
this.authError = undefined;
|
|
59
74
|
this.report(reason);
|
|
60
75
|
if (this.isTerminal(reason))
|
|
@@ -69,6 +84,13 @@ export class SocketBridge {
|
|
|
69
84
|
this.report(new BridgeError(packet.code, packet.retryable ? 'Realtime connection will be restored.' : 'Realtime access was revoked.'));
|
|
70
85
|
});
|
|
71
86
|
this.socket.on('disconnect', () => {
|
|
87
|
+
this.connectionGeneration++;
|
|
88
|
+
this.activeChannels.clear();
|
|
89
|
+
this.channelJoins.clear();
|
|
90
|
+
this.presenceSnapshots.clear();
|
|
91
|
+
for (const handles of this.channels.values())
|
|
92
|
+
for (const handle of handles)
|
|
93
|
+
handle.resetPresence();
|
|
72
94
|
this.clearRefresh();
|
|
73
95
|
for (const retry of this.channelRetries.values())
|
|
74
96
|
clearTimeout(retry.timer);
|
|
@@ -82,10 +104,33 @@ export class SocketBridge {
|
|
|
82
104
|
this.scheduleRefresh(Math.min(packet.refresh_after_ms, 2_147_483_647));
|
|
83
105
|
}
|
|
84
106
|
});
|
|
85
|
-
this.socket.on('bridge.
|
|
107
|
+
this.socket.on('bridge.presence', (packet) => {
|
|
108
|
+
if (!packet || typeof packet.channel !== 'string' || !Array.isArray(packet.members) || !this.desiredChannels.has(packet.channel))
|
|
109
|
+
return;
|
|
110
|
+
this.presenceSnapshots.set(packet.channel, packet.members);
|
|
111
|
+
for (const handle of this.channels.get(packet.channel) ?? [])
|
|
112
|
+
handle.receivePresence(packet.members);
|
|
113
|
+
});
|
|
114
|
+
this.socket.on('bridge.subscription.suspended', (packet) => {
|
|
115
|
+
this.activeChannels.delete(packet.channel);
|
|
116
|
+
this.presenceSnapshots.delete(packet.channel);
|
|
117
|
+
for (const handle of this.channels.get(packet.channel) ?? [])
|
|
118
|
+
handle.resetPresence();
|
|
119
|
+
});
|
|
120
|
+
this.socket.on('bridge.subscription.restored', (packet) => {
|
|
121
|
+
if (this.desiredChannels.has(packet.channel))
|
|
122
|
+
this.activeChannels.add(packet.channel);
|
|
123
|
+
void this.resync();
|
|
124
|
+
});
|
|
86
125
|
this.socket.on('bridge.subscription.revoked', (packet) => {
|
|
87
126
|
this.forgetChannel(packet.channel);
|
|
88
|
-
this.
|
|
127
|
+
this.presenceSnapshots.delete(packet.channel);
|
|
128
|
+
for (const handle of this.channels.get(packet.channel) ?? [])
|
|
129
|
+
handle.resetPresence();
|
|
130
|
+
const error = new BridgeError('subscription.revoked', `Access to ${packet.channel} was revoked.`);
|
|
131
|
+
for (const handle of this.channels.get(packet.channel) ?? [])
|
|
132
|
+
handle.receiveError(error);
|
|
133
|
+
this.report(error);
|
|
89
134
|
});
|
|
90
135
|
this.socket.on('bridge.command.result', (packet) => {
|
|
91
136
|
const item = this.pending.get(packet.command_id);
|
|
@@ -96,18 +141,43 @@ export class SocketBridge {
|
|
|
96
141
|
if (packet.result.ok)
|
|
97
142
|
item.resolve(packet.result.data);
|
|
98
143
|
else
|
|
99
|
-
item.reject(new BridgeError(packet.result.error?.code ?? 'command.failed', packet.result.error?.message ?? 'Command failed', packet.command_id));
|
|
144
|
+
item.reject(new BridgeError(packet.result.error?.code ?? 'command.failed', packet.result.error?.message ?? 'Command failed', packet.command_id, packet.result.error?.details));
|
|
100
145
|
});
|
|
101
146
|
}
|
|
102
147
|
connect() {
|
|
103
148
|
if (this.destroyed)
|
|
104
149
|
throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
|
|
150
|
+
this.managedConnection = false;
|
|
151
|
+
return this.beginConnection();
|
|
152
|
+
}
|
|
153
|
+
beginConnection() {
|
|
105
154
|
this.wantConnected = true;
|
|
106
155
|
this.terminal = false;
|
|
107
156
|
this.socket.connect();
|
|
108
157
|
return this;
|
|
109
158
|
}
|
|
159
|
+
/** Reference-count a framework consumer without closing a manually connected socket. */
|
|
160
|
+
retainConnection() {
|
|
161
|
+
if (this.destroyed)
|
|
162
|
+
throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
|
|
163
|
+
this.connectionReferences++;
|
|
164
|
+
if (!this.wantConnected) {
|
|
165
|
+
this.managedConnection = true;
|
|
166
|
+
this.beginConnection();
|
|
167
|
+
}
|
|
168
|
+
let released = false;
|
|
169
|
+
return () => {
|
|
170
|
+
if (released)
|
|
171
|
+
return;
|
|
172
|
+
released = true;
|
|
173
|
+
this.connectionReferences--;
|
|
174
|
+
if (this.connectionReferences === 0 && this.managedConnection)
|
|
175
|
+
this.disconnect();
|
|
176
|
+
};
|
|
177
|
+
}
|
|
110
178
|
disconnect() {
|
|
179
|
+
this.connectionGeneration++;
|
|
180
|
+
this.managedConnection = false;
|
|
111
181
|
this.wantConnected = false;
|
|
112
182
|
if (this.reconnectTimer)
|
|
113
183
|
clearTimeout(this.reconnectTimer);
|
|
@@ -116,6 +186,8 @@ export class SocketBridge {
|
|
|
116
186
|
for (const retry of this.channelRetries.values())
|
|
117
187
|
clearTimeout(retry.timer);
|
|
118
188
|
this.channelRetries.clear();
|
|
189
|
+
this.activeChannels.clear();
|
|
190
|
+
this.channelJoins.clear();
|
|
119
191
|
this.socket.disconnect();
|
|
120
192
|
}
|
|
121
193
|
destroy() {
|
|
@@ -128,24 +200,64 @@ export class SocketBridge {
|
|
|
128
200
|
}
|
|
129
201
|
this.pending.clear();
|
|
130
202
|
this.desiredChannels.clear();
|
|
203
|
+
this.manualChannels.clear();
|
|
204
|
+
this.presenceSnapshots.clear();
|
|
205
|
+
for (const handles of [...this.channels.values()])
|
|
206
|
+
for (const handle of [...handles])
|
|
207
|
+
handle.dispose();
|
|
208
|
+
this.channels.clear();
|
|
131
209
|
}
|
|
132
210
|
async join(channel) {
|
|
211
|
+
if (this.destroyed)
|
|
212
|
+
throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
|
|
213
|
+
this.manualChannels.add(channel);
|
|
133
214
|
this.desiredChannels.add(channel);
|
|
134
|
-
if (this.socket.connected)
|
|
135
|
-
|
|
136
|
-
await this.ack('room:join', { channel });
|
|
137
|
-
}
|
|
138
|
-
catch (error) {
|
|
139
|
-
this.failedSubscription(channel, error);
|
|
140
|
-
throw error;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
215
|
+
if (this.socket.connected)
|
|
216
|
+
await this.subscribeChannel(channel);
|
|
143
217
|
}
|
|
144
218
|
async leave(channel) {
|
|
219
|
+
this.manualChannels.delete(channel);
|
|
220
|
+
if (this.channels.get(channel)?.size)
|
|
221
|
+
return;
|
|
145
222
|
this.forgetChannel(channel);
|
|
146
223
|
if (this.socket.connected)
|
|
147
224
|
await this.ack('room:leave', { channel });
|
|
148
225
|
}
|
|
226
|
+
/** Create an independently disposable subscription to an exact canonical channel. */
|
|
227
|
+
channel(name) {
|
|
228
|
+
if (this.destroyed)
|
|
229
|
+
throw new BridgeError('client.destroyed', 'Create a new client after destroy().');
|
|
230
|
+
if (!/^[a-zA-Z0-9_.:-]{1,200}$/.test(name) || name.startsWith('__'))
|
|
231
|
+
throw new BridgeError('invalid_channel', 'Invalid or reserved channel.');
|
|
232
|
+
const handle = new BridgeChannel(this, name);
|
|
233
|
+
const handles = this.channels.get(name) ?? new Set();
|
|
234
|
+
handles.add(handle);
|
|
235
|
+
this.channels.set(name, handles);
|
|
236
|
+
this.desiredChannels.add(name);
|
|
237
|
+
const cached = this.presenceSnapshots.get(name);
|
|
238
|
+
if (cached)
|
|
239
|
+
handle.receivePresence(cached);
|
|
240
|
+
handle.ready = this.socket.connected ? this.subscribeChannel(name) : Promise.resolve();
|
|
241
|
+
// Listeners can use .error(); merely constructing a channel must not create an unhandled rejection.
|
|
242
|
+
void handle.ready.catch(() => undefined);
|
|
243
|
+
return handle;
|
|
244
|
+
}
|
|
245
|
+
private(name) { return this.channel(name.startsWith('private-') ? name : `private-${name}`); }
|
|
246
|
+
presence(name) { return this.channel(name.startsWith('presence-') ? name : `presence-${name}`); }
|
|
247
|
+
/** Internal lifetime hook used by channel handles. */
|
|
248
|
+
releaseChannel(handle) {
|
|
249
|
+
const handles = this.channels.get(handle.name);
|
|
250
|
+
handles?.delete(handle);
|
|
251
|
+
if (handles?.size)
|
|
252
|
+
return;
|
|
253
|
+
this.channels.delete(handle.name);
|
|
254
|
+
if (this.manualChannels.has(handle.name))
|
|
255
|
+
return;
|
|
256
|
+
this.forgetChannel(handle.name);
|
|
257
|
+
this.presenceSnapshots.delete(handle.name);
|
|
258
|
+
if (!this.destroyed && this.socket.connected)
|
|
259
|
+
void this.ack('room:leave', { channel: handle.name }).catch(error => this.report(error));
|
|
260
|
+
}
|
|
149
261
|
/** Dedupe is bounded and per listener, so multiple consumers all receive a new event. */
|
|
150
262
|
on(event, listener) {
|
|
151
263
|
const seen = new Set();
|
|
@@ -180,7 +292,7 @@ export class SocketBridge {
|
|
|
180
292
|
return;
|
|
181
293
|
clearTimeout(item.timer);
|
|
182
294
|
this.pending.delete(id);
|
|
183
|
-
reject(error instanceof BridgeError ? new BridgeError(error.code, error.message, id) : error);
|
|
295
|
+
reject(error instanceof BridgeError ? new BridgeError(error.code, error.message, id, error.details) : error);
|
|
184
296
|
});
|
|
185
297
|
});
|
|
186
298
|
}
|
|
@@ -216,17 +328,47 @@ export class SocketBridge {
|
|
|
216
328
|
throw new BridgeError('auth.invalid_response', 'Ticket response did not include a token.');
|
|
217
329
|
return token;
|
|
218
330
|
}
|
|
331
|
+
subscribeChannel(channel, attempts = 0) {
|
|
332
|
+
if (!this.socket.connected || this.destroyed || this.terminal)
|
|
333
|
+
return Promise.resolve();
|
|
334
|
+
const pending = this.channelJoins.get(channel);
|
|
335
|
+
if (pending)
|
|
336
|
+
return pending;
|
|
337
|
+
if (this.activeChannels.has(channel))
|
|
338
|
+
return Promise.resolve();
|
|
339
|
+
let request;
|
|
340
|
+
request = this.ack('room:join', { channel }).then(() => {
|
|
341
|
+
if (this.channelJoins.get(channel) === request && this.desiredChannels.has(channel))
|
|
342
|
+
this.activeChannels.add(channel);
|
|
343
|
+
}).catch(error => {
|
|
344
|
+
// A leave/rejoin or newer connection supersedes the old request and its eventual error.
|
|
345
|
+
if (this.channelJoins.get(channel) === request) {
|
|
346
|
+
this.failedSubscription(channel, error, attempts);
|
|
347
|
+
for (const handle of this.channels.get(channel) ?? [])
|
|
348
|
+
handle.receiveError(error);
|
|
349
|
+
this.report(error);
|
|
350
|
+
}
|
|
351
|
+
throw error;
|
|
352
|
+
}).finally(() => { if (this.channelJoins.get(channel) === request)
|
|
353
|
+
this.channelJoins.delete(channel); });
|
|
354
|
+
this.channelJoins.set(channel, request);
|
|
355
|
+
return request;
|
|
356
|
+
}
|
|
219
357
|
async restoreSubscriptions() {
|
|
220
|
-
|
|
358
|
+
const generation = this.connectionGeneration;
|
|
359
|
+
const current = () => generation === this.connectionGeneration && this.socket.connected && !this.destroyed && !this.terminal;
|
|
360
|
+
for (const channel of [...this.desiredChannels]) {
|
|
361
|
+
if (!current())
|
|
362
|
+
return;
|
|
363
|
+
if (!this.desiredChannels.has(channel))
|
|
364
|
+
continue;
|
|
221
365
|
try {
|
|
222
|
-
await this.
|
|
223
|
-
}
|
|
224
|
-
catch (error) {
|
|
225
|
-
this.failedSubscription(channel, error);
|
|
226
|
-
this.report(error);
|
|
366
|
+
await this.subscribeChannel(channel);
|
|
227
367
|
}
|
|
368
|
+
catch { /* subscribeChannel retains transient intent and reports the error. */ }
|
|
228
369
|
}
|
|
229
|
-
|
|
370
|
+
if (current())
|
|
371
|
+
await this.resync();
|
|
230
372
|
}
|
|
231
373
|
async resync() {
|
|
232
374
|
try {
|
|
@@ -240,6 +382,7 @@ export class SocketBridge {
|
|
|
240
382
|
return error instanceof BridgeError && ['unauthenticated', 'session_revoked', 'auth.revoked', 'forbidden', 'invalid_channel', 'subscription_cancelled'].includes(error.code);
|
|
241
383
|
}
|
|
242
384
|
stopForRevocation() {
|
|
385
|
+
this.connectionGeneration++;
|
|
243
386
|
this.terminal = true;
|
|
244
387
|
if (this.reconnectTimer)
|
|
245
388
|
clearTimeout(this.reconnectTimer);
|
|
@@ -297,6 +440,10 @@ export class SocketBridge {
|
|
|
297
440
|
}
|
|
298
441
|
forgetChannel(channel) {
|
|
299
442
|
this.desiredChannels.delete(channel);
|
|
443
|
+
this.manualChannels.delete(channel);
|
|
444
|
+
this.presenceSnapshots.delete(channel);
|
|
445
|
+
this.activeChannels.delete(channel);
|
|
446
|
+
this.channelJoins.delete(channel);
|
|
300
447
|
const retry = this.channelRetries.get(channel);
|
|
301
448
|
if (retry)
|
|
302
449
|
clearTimeout(retry.timer);
|
|
@@ -313,10 +460,7 @@ export class SocketBridge {
|
|
|
313
460
|
this.channelRetries.delete(channel);
|
|
314
461
|
if (!this.desiredChannels.has(channel) || !this.socket.connected || this.destroyed || this.terminal)
|
|
315
462
|
return;
|
|
316
|
-
void this.
|
|
317
|
-
this.failedSubscription(channel, next, attempts + 1);
|
|
318
|
-
this.report(next);
|
|
319
|
-
});
|
|
463
|
+
void this.subscribeChannel(channel, attempts + 1).then(() => this.resync(), () => undefined);
|
|
320
464
|
}, this.backoff(attempts));
|
|
321
465
|
this.channelRetries.set(channel, { attempts, timer });
|
|
322
466
|
}
|
|
@@ -326,7 +470,7 @@ export class SocketBridge {
|
|
|
326
470
|
if (error)
|
|
327
471
|
return reject(new BridgeError('socket.ack_timeout', 'Gateway acknowledgement timed out. The outcome may be unknown.'));
|
|
328
472
|
if (!response?.ok)
|
|
329
|
-
return reject(new BridgeError(response?.error?.code ?? 'socket.rejected', response?.error?.message ?? 'Gateway rejected the request.'));
|
|
473
|
+
return reject(new BridgeError(response?.error?.code ?? 'socket.rejected', response?.error?.message ?? 'Gateway rejected the request.', undefined, response?.error?.details));
|
|
330
474
|
resolve(response);
|
|
331
475
|
});
|
|
332
476
|
});
|
package/dist/react.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { EventMetadata, JsonObject, PresenceMember, SocketBridge } from './index.js';
|
|
2
|
+
/** Share one application-created bridge across components; the last managed consumer disconnects. */
|
|
3
|
+
export declare function useSocketBridge(bridge: SocketBridge): boolean;
|
|
4
|
+
/** Exact canonical channel name, e.g. private-orders.42. Each mounted hook owns one reference. */
|
|
5
|
+
export declare function useChannelEvent<T = JsonObject>(bridge: SocketBridge, channel: string, event: string, listener: (payload: T, metadata?: EventMetadata) => void, onError?: (error: Error) => void): void;
|
|
6
|
+
/** Name without presence- is accepted. Members are authoritative snapshots, not attendance history. */
|
|
7
|
+
export declare function usePresence(bridge: SocketBridge, channel: string): {
|
|
8
|
+
members: PresenceMember[];
|
|
9
|
+
error: Error | null;
|
|
10
|
+
};
|
package/dist/react.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState, useSyncExternalStore } from 'react';
|
|
2
|
+
/** Share one application-created bridge across components; the last managed consumer disconnects. */
|
|
3
|
+
export function useSocketBridge(bridge) {
|
|
4
|
+
useEffect(() => bridge.retainConnection(), [bridge]);
|
|
5
|
+
const subscribe = useCallback((notify) => {
|
|
6
|
+
bridge.socket.on('connect', notify);
|
|
7
|
+
bridge.socket.on('disconnect', notify);
|
|
8
|
+
return () => { bridge.socket.off('connect', notify); bridge.socket.off('disconnect', notify); };
|
|
9
|
+
}, [bridge]);
|
|
10
|
+
return useSyncExternalStore(subscribe, () => bridge.socket.connected, () => false);
|
|
11
|
+
}
|
|
12
|
+
/** Exact canonical channel name, e.g. private-orders.42. Each mounted hook owns one reference. */
|
|
13
|
+
export function useChannelEvent(bridge, channel, event, listener, onError) {
|
|
14
|
+
const latest = useRef({ listener, onError });
|
|
15
|
+
useEffect(() => { latest.current = { listener, onError }; }, [listener, onError]);
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
const handle = bridge.channel(channel).listen(event, (payload, metadata) => latest.current.listener(payload, metadata))
|
|
18
|
+
.error(error => latest.current.onError?.(error));
|
|
19
|
+
return () => handle.dispose();
|
|
20
|
+
}, [bridge, channel, event]);
|
|
21
|
+
}
|
|
22
|
+
/** Name without presence- is accepted. Members are authoritative snapshots, not attendance history. */
|
|
23
|
+
export function usePresence(bridge, channel) {
|
|
24
|
+
const [state, setState] = useState({ bridge, channel, members: [], error: null });
|
|
25
|
+
useEffect(() => {
|
|
26
|
+
const handle = bridge.presence(channel)
|
|
27
|
+
.here(members => setState({ bridge, channel, members, error: null }))
|
|
28
|
+
.error(error => setState(current => ({ bridge, channel, members: current.bridge === bridge && current.channel === channel ? current.members : [], error })));
|
|
29
|
+
const reset = () => setState({ bridge, channel, members: [], error: null });
|
|
30
|
+
bridge.socket.on('disconnect', reset);
|
|
31
|
+
return () => { bridge.socket.off('disconnect', reset); handle.dispose(); };
|
|
32
|
+
}, [bridge, channel]);
|
|
33
|
+
return state.bridge === bridge && state.channel === channel ? state : { members: [], error: null };
|
|
34
|
+
}
|
package/dist/vue.d.ts
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { type MaybeRefOrGetter } from 'vue';
|
|
2
|
+
import type { EventMetadata, JsonObject, SocketBridge } from './index.js';
|
|
3
|
+
/** No connection is opened during component SSR. Scope cleanup releases only this consumer. */
|
|
4
|
+
export declare function useSocketBridge(bridge: SocketBridge): Readonly<import("vue").Ref<boolean, boolean>>;
|
|
5
|
+
/** Reactive canonical name; old listeners and subscriptions are released when it changes. */
|
|
6
|
+
export declare function useChannelEvent<T = JsonObject>(bridge: SocketBridge, channel: MaybeRefOrGetter<string>, event: MaybeRefOrGetter<string>, listener: (payload: T, metadata?: EventMetadata) => void, onError?: (error: Error) => void): void;
|
|
7
|
+
export declare function usePresence(bridge: SocketBridge, channel: MaybeRefOrGetter<string>): {
|
|
8
|
+
members: Readonly<import("vue").Ref<readonly {
|
|
9
|
+
readonly id: string;
|
|
10
|
+
readonly info: {
|
|
11
|
+
readonly [x: string]: Readonly<unknown>;
|
|
12
|
+
};
|
|
13
|
+
}[], readonly {
|
|
14
|
+
readonly id: string;
|
|
15
|
+
readonly info: {
|
|
16
|
+
readonly [x: string]: Readonly<unknown>;
|
|
17
|
+
};
|
|
18
|
+
}[]>>;
|
|
19
|
+
error: Readonly<import("vue").Ref<Error | null, Error | null>>;
|
|
20
|
+
};
|
package/dist/vue.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { getCurrentInstance, getCurrentScope, onMounted, onScopeDispose, readonly, shallowRef, toValue, watch } from 'vue';
|
|
2
|
+
function assertScope() { if (!getCurrentScope())
|
|
3
|
+
throw new Error('Socket Bridge composables require a component setup() or an active effectScope().'); }
|
|
4
|
+
/** No connection is opened during component SSR. Scope cleanup releases only this consumer. */
|
|
5
|
+
export function useSocketBridge(bridge) {
|
|
6
|
+
assertScope();
|
|
7
|
+
const connected = shallowRef(bridge.socket.connected);
|
|
8
|
+
const update = () => { connected.value = bridge.socket.connected; };
|
|
9
|
+
bridge.socket.on('connect', update);
|
|
10
|
+
bridge.socket.on('disconnect', update);
|
|
11
|
+
let release;
|
|
12
|
+
const connect = () => { release = bridge.retainConnection(); update(); };
|
|
13
|
+
if (getCurrentInstance())
|
|
14
|
+
onMounted(connect);
|
|
15
|
+
else
|
|
16
|
+
connect();
|
|
17
|
+
onScopeDispose(() => { bridge.socket.off('connect', update); bridge.socket.off('disconnect', update); release?.(); });
|
|
18
|
+
return readonly(connected);
|
|
19
|
+
}
|
|
20
|
+
/** Reactive canonical name; old listeners and subscriptions are released when it changes. */
|
|
21
|
+
export function useChannelEvent(bridge, channel, event, listener, onError) {
|
|
22
|
+
assertScope();
|
|
23
|
+
watch(() => [toValue(channel), toValue(event)], ([name, eventName], _previous, onCleanup) => {
|
|
24
|
+
const handle = bridge.channel(name).listen(eventName, listener).error(error => onError?.(error));
|
|
25
|
+
onCleanup(() => handle.dispose());
|
|
26
|
+
}, { immediate: true, flush: 'sync' });
|
|
27
|
+
}
|
|
28
|
+
export function usePresence(bridge, channel) {
|
|
29
|
+
assertScope();
|
|
30
|
+
const members = shallowRef([]);
|
|
31
|
+
const error = shallowRef(null);
|
|
32
|
+
const reset = () => { members.value = []; error.value = null; };
|
|
33
|
+
bridge.socket.on('disconnect', reset);
|
|
34
|
+
watch(() => toValue(channel), (name, _previous, onCleanup) => {
|
|
35
|
+
reset();
|
|
36
|
+
const handle = bridge.presence(name).here(value => { members.value = value; error.value = null; }).error(value => { error.value = value; });
|
|
37
|
+
onCleanup(() => handle.dispose());
|
|
38
|
+
}, { immediate: true, flush: 'sync' });
|
|
39
|
+
onScopeDispose(() => bridge.socket.off('disconnect', reset));
|
|
40
|
+
return { members: readonly(members), error: readonly(error) };
|
|
41
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@belysh/socket-bridge-client",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Framework-neutral Socket.IO client for Laravel Socket Bridge",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -10,6 +10,14 @@
|
|
|
10
10
|
".": {
|
|
11
11
|
"types": "./dist/index.d.ts",
|
|
12
12
|
"import": "./dist/index.js"
|
|
13
|
+
},
|
|
14
|
+
"./react": {
|
|
15
|
+
"types": "./dist/react.d.ts",
|
|
16
|
+
"import": "./dist/react.js"
|
|
17
|
+
},
|
|
18
|
+
"./vue": {
|
|
19
|
+
"types": "./dist/vue.d.ts",
|
|
20
|
+
"import": "./dist/vue.js"
|
|
13
21
|
}
|
|
14
22
|
},
|
|
15
23
|
"files": [
|
|
@@ -26,7 +34,13 @@
|
|
|
26
34
|
"socket.io-client": "^4.8.1"
|
|
27
35
|
},
|
|
28
36
|
"devDependencies": {
|
|
29
|
-
"typescript": "^5.9.3"
|
|
37
|
+
"typescript": "^5.9.3",
|
|
38
|
+
"react": "^19.0.0",
|
|
39
|
+
"react-dom": "^19.0.0",
|
|
40
|
+
"@types/react": "^19.0.0",
|
|
41
|
+
"@types/react-dom": "^19.0.0",
|
|
42
|
+
"jsdom": "^27.0.0",
|
|
43
|
+
"vue": "^3.5.0"
|
|
30
44
|
},
|
|
31
45
|
"engines": {
|
|
32
46
|
"node": ">=20"
|
|
@@ -46,5 +60,17 @@
|
|
|
46
60
|
"socket.io",
|
|
47
61
|
"redis",
|
|
48
62
|
"realtime"
|
|
49
|
-
]
|
|
63
|
+
],
|
|
64
|
+
"peerDependencies": {
|
|
65
|
+
"react": "^18.0.0 || ^19.0.0",
|
|
66
|
+
"vue": "^3.5.0"
|
|
67
|
+
},
|
|
68
|
+
"peerDependenciesMeta": {
|
|
69
|
+
"react": {
|
|
70
|
+
"optional": true
|
|
71
|
+
},
|
|
72
|
+
"vue": {
|
|
73
|
+
"optional": true
|
|
74
|
+
}
|
|
75
|
+
}
|
|
50
76
|
}
|